mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-22 15:09:04 +00:00
Compare commits
1111 Commits
v2.2.3
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2d37089d3 | ||
|
|
33fde44cc3 | ||
|
|
fc9b1ead9e | ||
|
|
c5f629ac4a | ||
|
|
898d2c7f29 | ||
|
|
4aa0f517bf | ||
|
|
682f43bf2f | ||
|
|
bc2e7d616a | ||
|
|
ef2bbe5c22 | ||
|
|
4de4a74eca | ||
|
|
0ba1067123 | ||
|
|
b7c7ca4376 | ||
|
|
c91163abac | ||
|
|
f40f3225df | ||
|
|
3a99eb8338 | ||
|
|
a902ef70d9 | ||
|
|
c9498d5079 | ||
|
|
b9d8b303a1 | ||
|
|
c94405e5bb | ||
|
|
9697dcb703 | ||
|
|
55ee72225e | ||
|
|
e47eaf273e | ||
|
|
aa16c87afc | ||
|
|
bd439b7179 | ||
|
|
678c08b507 | ||
|
|
216a6011bd | ||
|
|
e12caa16a6 | ||
|
|
55885449a3 | ||
|
|
06c020d9ca | ||
|
|
da84623898 | ||
|
|
5221d427ed | ||
|
|
6c84e0c35a | ||
|
|
167ce3fae0 | ||
|
|
b8bcfa23be | ||
|
|
3bff868df1 | ||
|
|
74012ab252 | ||
|
|
574ba94e0e | ||
|
|
6fdeaacb5c | ||
|
|
a1ab0834b7 | ||
|
|
ade07b8578 | ||
|
|
00bd632ad9 | ||
|
|
e83fcfdc4c | ||
|
|
95dd2ea551 | ||
|
|
a36da9d565 | ||
|
|
111a1961bf | ||
|
|
fd4a214f9f | ||
|
|
f9122492db | ||
|
|
ab1d64e0c9 | ||
|
|
93bafbd9f7 | ||
|
|
419a53d6ec | ||
|
|
2b22975933 | ||
|
|
e049bfd606 | ||
|
|
a377669b73 | ||
|
|
5da4454af9 | ||
|
|
5588721566 | ||
|
|
2e77d9468a | ||
|
|
9f45c3f5eb | ||
|
|
1921d36e17 | ||
|
|
72569a520e | ||
|
|
00b63eed54 | ||
|
|
9af1a0ad56 | ||
|
|
7aeff80bf9 | ||
|
|
0d387f05de | ||
|
|
f40b039426 | ||
|
|
0cbba05263 | ||
|
|
1904aa918e | ||
|
|
a05cde93bd | ||
|
|
8f7ece7691 | ||
|
|
86daa8ef06 | ||
|
|
24eceef6cb | ||
|
|
4ba567ca09 | ||
|
|
4446d9439d | ||
|
|
6225df296c | ||
|
|
9f3736ef40 | ||
|
|
1be03734a4 | ||
|
|
7435ab49ab | ||
|
|
9c5426159d | ||
|
|
f3bb548626 | ||
|
|
34cdaa508c | ||
|
|
a734cedac1 | ||
|
|
5da98ddc8a | ||
|
|
e79d18da03 | ||
|
|
69a598f196 | ||
|
|
ac84606f20 | ||
|
|
b086507569 | ||
|
|
360f4917b1 | ||
|
|
89d0f22dac | ||
|
|
f4d63d01bd | ||
|
|
59a0b1bf16 | ||
|
|
48ca54a856 | ||
|
|
bf3dfbba0f | ||
|
|
bd1bd8a8aa | ||
|
|
7e1ca95bef | ||
|
|
b7cb2cd42d | ||
|
|
6359123323 | ||
|
|
f2f78bb4e2 | ||
|
|
716b21b0dd | ||
|
|
cde3590986 | ||
|
|
f89ad6ec15 | ||
|
|
4efa169313 | ||
|
|
933912f15d | ||
|
|
4e216ce036 | ||
|
|
567fcd3683 | ||
|
|
49ab0de7b3 | ||
|
|
0f34222954 | ||
|
|
caf5b0c9db | ||
|
|
f2d6188c53 | ||
|
|
b9af7ffc8c | ||
|
|
5bec4f3cd6 | ||
|
|
726edfa850 | ||
|
|
ff33242887 | ||
|
|
a26d5620ca | ||
|
|
8a3f1078f6 | ||
|
|
56b767ff46 | ||
|
|
102eb14b0b | ||
|
|
e57b9d07f1 | ||
|
|
3be90d00e5 | ||
|
|
efb5cd3586 | ||
|
|
86b1043134 | ||
|
|
36bed846b2 | ||
|
|
9d3d38fa7e | ||
|
|
ddf6b63aec | ||
|
|
079779c2c6 | ||
|
|
afa8bb5fe0 | ||
|
|
127668ae22 | ||
|
|
b00264d060 | ||
|
|
2e135587d4 | ||
|
|
571bffa923 | ||
|
|
bc355d43a0 | ||
|
|
e2a207be92 | ||
|
|
397cc888db | ||
|
|
22a2616534 | ||
|
|
d6c9a10766 | ||
|
|
657e8015b2 | ||
|
|
fc3612abb2 | ||
|
|
8d79a82ac2 | ||
|
|
234cf690f0 | ||
|
|
d768c8d08c | ||
|
|
e98e9cb7d9 | ||
|
|
8e8f1b3d22 | ||
|
|
5b6117ec28 | ||
|
|
33188485b7 | ||
|
|
08bd5e5435 | ||
|
|
714827a36d | ||
|
|
7a51d8cf64 | ||
|
|
902d2c9c74 | ||
|
|
d96000f0d9 | ||
|
|
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 |
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"
|
||||||
212
.github/scripts/release-utils.sh
vendored
Normal file
212
.github/scripts/release-utils.sh
vendored
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
retry_cmd() {
|
||||||
|
local attempts="$1"
|
||||||
|
local delay_seconds="$2"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
local i
|
||||||
|
local exit_code
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
if "$@"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
exit_code=$?
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
|
||||||
|
echo "Retrying in ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Command failed after $attempts attempts: $*" >&2
|
||||||
|
return "$exit_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
capture_cmd_with_retry() {
|
||||||
|
local result_var="$1"
|
||||||
|
local attempts="$2"
|
||||||
|
local delay_seconds="$3"
|
||||||
|
shift 3
|
||||||
|
|
||||||
|
local i
|
||||||
|
local output=""
|
||||||
|
local exit_code=1
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
if output="$("$@" 2>/dev/null)"; then
|
||||||
|
printf -v "$result_var" "%s" "$output"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
exit_code=$?
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Capture command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
|
||||||
|
echo "Retrying in ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Capture command failed after $attempts attempts: $*" >&2
|
||||||
|
return "$exit_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_release_id() {
|
||||||
|
local repo="$1"
|
||||||
|
local tag="$2"
|
||||||
|
local attempts="${3:-12}"
|
||||||
|
local delay_seconds="${4:-2}"
|
||||||
|
|
||||||
|
local i
|
||||||
|
local release_id
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
release_id="$(gh api "repos/$repo/releases/tags/$tag" --jq '.id' 2>/dev/null || true)"
|
||||||
|
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "$release_id"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Release id for tag '$tag' is not ready yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Unable to fetch release id for tag '$tag' after $attempts attempts." >&2
|
||||||
|
gh api "repos/$repo/releases/tags/$tag" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
settle_release_state() {
|
||||||
|
local repo="$1"
|
||||||
|
local release_id="$2"
|
||||||
|
local tag="$3"
|
||||||
|
local attempts="${4:-12}"
|
||||||
|
local delay_seconds="${5:-2}"
|
||||||
|
local endpoint="repos/$repo/releases/tags/$tag"
|
||||||
|
|
||||||
|
local i
|
||||||
|
local draft_state
|
||||||
|
local prerelease_state
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
gh api --method PATCH "repos/$repo/releases/$release_id" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||||
|
draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || echo true)"
|
||||||
|
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||||
|
if [ "$draft_state" = "false" ] && [ "$prerelease_state" = "true" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Release '$tag' state not settled yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Failed to settle release state for tag '$tag'." >&2
|
||||||
|
gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_release_absent() {
|
||||||
|
local repo="$1"
|
||||||
|
local tag="$2"
|
||||||
|
local attempts="${3:-12}"
|
||||||
|
local delay_seconds="${4:-2}"
|
||||||
|
|
||||||
|
local i
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Release '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Release '$tag' still exists after waiting." >&2
|
||||||
|
gh release view "$tag" --repo "$repo" --json url,isDraft,isPrerelease 2>/dev/null || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_git_tag_absent() {
|
||||||
|
local repo="$1"
|
||||||
|
local tag="$2"
|
||||||
|
local attempts="${3:-12}"
|
||||||
|
local delay_seconds="${4:-2}"
|
||||||
|
|
||||||
|
local i
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Git tag '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Git tag '$tag' still exists after waiting." >&2
|
||||||
|
gh api "repos/$repo/git/ref/tags/$tag" --jq '{ref: .ref, object: .object.sha}' 2>/dev/null || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
recreate_fixed_prerelease() {
|
||||||
|
local repo="$1"
|
||||||
|
local tag="$2"
|
||||||
|
local target_branch="$3"
|
||||||
|
local release_title="$4"
|
||||||
|
local release_notes="$5"
|
||||||
|
|
||||||
|
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
|
||||||
|
retry_cmd 5 3 gh release delete "$tag" --repo "$repo" --yes --cleanup-tag
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait_for_release_absent "$repo" "$tag" 12 2
|
||||||
|
|
||||||
|
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
|
||||||
|
retry_cmd 5 2 gh api --method DELETE "repos/$repo/git/refs/tags/$tag"
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait_for_git_tag_absent "$repo" "$tag" 12 2
|
||||||
|
|
||||||
|
local created="false"
|
||||||
|
local i
|
||||||
|
for ((i = 1; i <= 6; i++)); do
|
||||||
|
if gh release create "$tag" --repo "$repo" --title "$release_title" --notes "$release_notes" --prerelease --target "$target_branch"; then
|
||||||
|
created="true"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
|
||||||
|
echo "Release '$tag' appears to exist after create failure; continue to settle state." >&2
|
||||||
|
created="true"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -lt 6 ]; then
|
||||||
|
echo "Create release '$tag' failed (attempt $i/6), retrying in 3s..." >&2
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$created" != "true" ]; then
|
||||||
|
echo "Failed to create release '$tag'." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local release_id
|
||||||
|
release_id="$(wait_for_release_id "$repo" "$tag" 12 2)"
|
||||||
|
settle_release_state "$repo" "$release_id" "$tag" 12 2
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_release_assets_with_retry() {
|
||||||
|
local repo="$1"
|
||||||
|
local tag="$2"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
echo "No release assets provided for upload." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait_for_release_id "$repo" "$tag" 12 2 >/dev/null
|
||||||
|
retry_cmd 5 3 gh release upload "$tag" "$@" --repo "$repo" --clobber
|
||||||
|
}
|
||||||
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}`);
|
||||||
|
}
|
||||||
383
.github/workflows/dev-daily-fixed.yml
vendored
Normal file
383
.github/workflows/dev-daily-fixed.yml
vendored
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
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
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "$TARGET_BRANCH" "Daily Dev Build" "开发版发布页"
|
||||||
|
|
||||||
|
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: Ensure mac key helpers are executable
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for file in \
|
||||||
|
resources/key/macos/universal/xkey_helper \
|
||||||
|
resources/key/macos/universal/image_scan_helper \
|
||||||
|
resources/key/macos/universal/xkey_helper_macos \
|
||||||
|
resources/key/macos/universal/libwx_key.dylib
|
||||||
|
do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
chmod +x "$file"
|
||||||
|
ls -l "$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- 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: |
|
||||||
|
set -euo pipefail
|
||||||
|
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"
|
||||||
|
if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'; then
|
||||||
|
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
|
||||||
|
npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload macOS arm64 assets to fixed release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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 }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TAG="${FIXED_DEV_TAG:-}"
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
echo "FIXED_DEV_TAG is empty, abort."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
REPO="$GITHUB_REPOSITORY"
|
||||||
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
echo "Using release tag: $TAG"
|
||||||
|
|
||||||
|
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||||
|
echo "Release $TAG not found, skip notes update."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
|
||||||
|
|
||||||
|
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$")"
|
||||||
|
if [ -z "$MAC_ASSET" ]; then
|
||||||
|
MAC_ASSET="$(pick_asset "dev-arm64[.]zip$")"
|
||||||
|
fi
|
||||||
|
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
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
||||||
|
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
||||||
|
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_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(", ")}`);
|
||||||
426
.github/workflows/preview-nightly-main.yml
vendored
Normal file
426
.github/workflows/preview-nightly-main.yml
vendored
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
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
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "$TARGET_BRANCH" "Preview Nightly Build" "预览版发布页"
|
||||||
|
|
||||||
|
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: Ensure mac key helpers are executable
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for file in \
|
||||||
|
resources/key/macos/universal/xkey_helper \
|
||||||
|
resources/key/macos/universal/image_scan_helper \
|
||||||
|
resources/key/macos/universal/xkey_helper_macos \
|
||||||
|
resources/key/macos/universal/libwx_key.dylib
|
||||||
|
do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
chmod +x "$file"
|
||||||
|
ls -l "$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- 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: |
|
||||||
|
set -euo pipefail
|
||||||
|
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"
|
||||||
|
if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'; then
|
||||||
|
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
|
||||||
|
npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload macOS arm64 assets to fixed preview release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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:-}"
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
echo "FIXED_PREVIEW_TAG is empty, abort."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}"
|
||||||
|
REPO="$GITHUB_REPOSITORY"
|
||||||
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
echo "Using release tag: $TAG"
|
||||||
|
|
||||||
|
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||||
|
echo "Release $TAG not found (possibly all publish jobs failed), skip notes update."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
|
||||||
|
|
||||||
|
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$")"
|
||||||
|
if [ -z "$MAC_ASSET" ]; then
|
||||||
|
MAC_ASSET="$(pick_asset "[.]zip$")"
|
||||||
|
fi
|
||||||
|
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
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
||||||
|
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
||||||
|
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||||
318
.github/workflows/release.yml
vendored
318
.github/workflows/release.yml
vendored
@@ -8,33 +8,161 @@ 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
|
||||||
run: |
|
run: |
|
||||||
VERSION=${GITHUB_REF_NAME#v}
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
echo "Syncing package.json version to $VERSION"
|
echo "Syncing package.json version to $VERSION"
|
||||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||||
|
|
||||||
- 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 + ZIP)
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
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"
|
||||||
|
if ! npx electron-builder --mac dmg zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'; then
|
||||||
|
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
|
||||||
|
npx electron-builder --mac zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Inject minimumVersion into latest yml
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||||
|
for YML_FILE in latest-mac.yml latest-arm64-mac.yml; do
|
||||||
|
if ! retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "$YML_FILE" --output "/tmp/$YML_FILE"; then
|
||||||
|
echo "Skip $YML_FILE because download failed after retries."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! grep -q 'minimumVersion' "/tmp/$YML_FILE"; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> "/tmp/$YML_FILE"
|
||||||
|
fi
|
||||||
|
retry_cmd 5 3 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: Ensure linux key helper is executable
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
[ -f "resources/key/linux/x64/xkey_helper" ] && chmod +x "resources/key/linux/x64/xkey_helper" || echo "File not found"
|
||||||
|
|
||||||
|
- name: Sync version with tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
|
echo "Syncing package.json version to $VERSION"
|
||||||
|
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||||
|
|
||||||
|
- 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 '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
|
||||||
|
|
||||||
|
- name: Inject minimumVersion into latest yml
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||||
|
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" || true
|
||||||
|
if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml
|
||||||
|
retry_cmd 5 3 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"
|
||||||
|
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
@@ -43,19 +171,183 @@ 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.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--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
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||||
|
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" || true
|
||||||
|
if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml
|
||||||
|
retry_cmd 5 3 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"
|
||||||
|
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||||
|
|
||||||
|
- 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.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--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: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||||
|
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" || true
|
||||||
|
if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml
|
||||||
|
retry_cmd 5 3 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
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
|
||||||
|
TAG="$GITHUB_REF_NAME"
|
||||||
|
REPO="$GITHUB_REPOSITORY"
|
||||||
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||||
|
|
||||||
|
capture_cmd_with_retry ASSETS_JSON 8 3 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$")"
|
||||||
|
if [ -z "$MAC_ASSET" ]; then
|
||||||
|
MAC_ASSET="$(pick_asset "arm64\\.zip$")"
|
||||||
|
fi
|
||||||
|
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/weflow_cc)
|
[点击加入 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
|
retry_cmd 5 3 gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
||||||
|
|
||||||
|
deploy-aur:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [release-linux]
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Update PKGBUILD version
|
||||||
|
run: |
|
||||||
|
NEW_VER=$(echo "${{ github.ref_name }}" | sed 's/^v//')
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=${NEW_VER}/" resources/installer/linux/PKGBUILD
|
||||||
|
sed -i "s/^pkgrel=.*/pkgrel=1/" resources/installer/linux/PKGBUILD
|
||||||
|
|
||||||
|
- name: Publish AUR package
|
||||||
|
uses: KSXGitHub/github-actions-deploy-aur@master
|
||||||
|
with:
|
||||||
|
pkgname: weflow
|
||||||
|
pkgbuild: resources/installer/linux/PKGBUILD
|
||||||
|
updpkgsums: true
|
||||||
|
assets: |
|
||||||
|
resources/installer/linux/weflow.desktop
|
||||||
|
resources/installer/linux/icon.png
|
||||||
|
|
||||||
|
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||||
|
commit_username: H3CoF6
|
||||||
|
commit_email: h3cof6@gmail.com
|
||||||
|
ssh_keyscan_types: ed25519
|
||||||
|
|||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -56,11 +56,23 @@ Thumbs.db
|
|||||||
*.aps
|
*.aps
|
||||||
|
|
||||||
wcdb/
|
wcdb/
|
||||||
|
!resources/wcdb/
|
||||||
|
!resources/wcdb/**
|
||||||
|
xkey/
|
||||||
|
server/
|
||||||
*info
|
*info
|
||||||
概述.md
|
|
||||||
chatlab-format.md
|
chatlab-format.md
|
||||||
*.bak
|
*.bak
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
AGENT.md
|
||||||
.claude/
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
.agents/
|
.agents/
|
||||||
resources/wx_send
|
resources/wx_send
|
||||||
|
概述.md
|
||||||
|
pnpm-lock.yaml
|
||||||
|
/pnpm-workspace.yaml
|
||||||
|
wechat-research-site
|
||||||
|
.codex
|
||||||
|
weflow-web-offical
|
||||||
|
/Wedecrypt
|
||||||
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/
|
|
||||||
|
|||||||
91
README.md
91
README.md
@@ -1,32 +1,23 @@
|
|||||||
# WeFlow
|
# WeFlow
|
||||||
|
|
||||||
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告
|
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="app.png" alt="WeFlow" width="90%">
|
<img src="app.png" alt="WeFlow 应用预览" width="90%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/hicccc77/WeFlow/stargazers">
|
<!-- 第一行修复样式 -->
|
||||||
<img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat-square" alt="Stargazers">
|
<a href="https://github.com/hicccc77/WeFlow/stargazers"><img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat&label=Stars&labelColor=1F2937&color=2563EB" alt="Stargazers"></a>
|
||||||
</a>
|
<a href="https://github.com/hicccc77/WeFlow/network/members"><img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat&label=Forks&labelColor=1F2937&color=7C3AED" alt="Forks"></a>
|
||||||
<a href="https://github.com/hicccc77/WeFlow/network/members">
|
<a href="https://github.com/hicccc77/WeFlow/issues"><img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat&label=Issues&labelColor=1F2937&color=D97706" alt="Issues"></a>
|
||||||
<img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat-square" alt="Forks">
|
<a href="https://github.com/hicccc77/WeFlow/releases"><img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat&label=Downloads&labelColor=1F2937&color=059669" alt="Downloads"></a>
|
||||||
</a>
|
<br><br>
|
||||||
<a href="https://github.com/hicccc77/WeFlow/issues">
|
<!-- 第二行:电报矮一点(22px),排名高一点(32px),使用 vertical-align: middle 居中对齐 -->
|
||||||
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
<a href="https://t.me/weflow_cc"><img src="https://img.shields.io/badge/Telegram-频道-1D9BF0?style=flat&logo=telegram&logoColor=white&labelColor=1F2937&color=1D9BF0" alt="Telegram Channel" style="height: 22px; vertical-align: middle;"></a>
|
||||||
<img src="https://gh-down-badges.linkof.link/hicccc77/WeFlow/" alt="Downloads" />
|
<a href="https://www.star-history.com/hicccc77/weflow"><img src="https://api.star-history.com/badge?repo=hicccc77/WeFlow&theme=dark" alt="Star History Rank" style="height: 32px; vertical-align: middle;"></a>
|
||||||
</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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
||||||
|
|
||||||
@@ -41,7 +32,39 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
- 年度报告与可视化概览
|
- 年度报告与可视化概览
|
||||||
- 导出聊天记录为 HTML 等格式
|
- 导出聊天记录为 HTML 等格式
|
||||||
- HTTP API 接口(供开发者集成)
|
- HTTP API 接口(供开发者集成)
|
||||||
|
- 查看完整能力清单:[详细功能](#详细功能清单)
|
||||||
|
|
||||||
|
## 支持平台与设备
|
||||||
|
|
||||||
|
| 平台 | 设备/架构 | 安装包 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| Windows | Windows10+、x64(amd64) | `.exe` |
|
||||||
|
| macOS | Apple Silicon(M 系列,arm64) | `.dmg` |
|
||||||
|
| Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` |
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
||||||
|
|
||||||
|
> ArchLinux 用户可以选择 `yay -S weflow` 快速安装
|
||||||
|
|
||||||
|
## 详细功能清单
|
||||||
|
|
||||||
|
当前版本已支持以下能力:
|
||||||
|
|
||||||
|
| 功能模块 | 说明 |
|
||||||
|
|---------|------|
|
||||||
|
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
||||||
|
| **消息防撤回** | 防止其他人发送的消息被撤回 |
|
||||||
|
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
||||||
|
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
||||||
|
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
||||||
|
| **年度报告** | 生成按年统计的年度报告,或跨年度的长期历史报告 |
|
||||||
|
| **双人报告** | 选择指定好友,基于双方聊天记录生成专属分析报告 |
|
||||||
|
| **消息导出** | 将微信聊天记录导出为多种格式:JSON、HTML、TXT、Excel、CSV、PGSQL、ChatLab专属格式等 |
|
||||||
|
| **朋友圈** | 解密朋友圈图片、视频、实况;导出朋友圈内容;拦截朋友圈的删除与隐藏操作;突破时间访问限制 |
|
||||||
|
| **联系人** | 导出微信好友、群聊、公众号信息;尝试找回曾经的好友(功能尚不完善) |
|
||||||
|
| **HTTP API 映射** | 将本地消息能力映射为 HTTP API,便于对接外部系统、自动化脚本与二次开发 |
|
||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
@@ -55,12 +78,7 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
|
|||||||
- **访问地址**:`http://127.0.0.1:5031`
|
- **访问地址**:`http://127.0.0.1:5031`
|
||||||
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
|
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
|
||||||
|
|
||||||
📖 完整接口文档:[点击查看](docs/HTTP-API.md)
|
完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||||
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
若你只想使用成品版本,可前往 Release 下载并安装。
|
|
||||||
|
|
||||||
## 面向开发者
|
## 面向开发者
|
||||||
|
|
||||||
@@ -76,15 +94,8 @@ 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) 为本项目提供了基础框架
|
||||||
@@ -94,18 +105,16 @@ npm run build
|
|||||||
|
|
||||||
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
|
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
|
||||||
|
|
||||||
|
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
||||||
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
|
||||||
|
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
<a href="https://www.star-history.com/#hicccc77/WeFlow&type=date&legend=top-left">
|
<a href="https://www.star-history.com/#hicccc77/WeFlow&type=date&legend=top-left">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&theme=dark&legend=top-left" />
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&theme=dark&legend=top-left" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
|
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|||||||
837
docs/HTTP-API.md
837
docs/HTTP-API.md
@@ -1,33 +1,56 @@
|
|||||||
# WeFlow HTTP API 接口文档
|
# WeFlow HTTP API / Push 文档
|
||||||
|
|
||||||
WeFlow 提供 HTTP API 服务,支持通过 HTTP 接口查询消息数据,支持 [ChatLab](https://github.com/nichuanfang/chatlab-format) 标准化格式输出。
|
WeFlow 提供本地 HTTP API(已支持GET 和 POST请求),便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
||||||
|
|
||||||
## 启用 API 服务
|
## 启用方式
|
||||||
|
|
||||||
在设置页面 → API 服务 → 点击「启动服务」按钮。
|
在应用设置页启用 `API 服务`。
|
||||||
|
|
||||||
默认端口:`5031`
|
- 默认监听地址:`127.0.0.1`
|
||||||
|
- 默认端口:`5031`
|
||||||
|
- 基础地址:`http://127.0.0.1:5031`
|
||||||
|
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
||||||
|
|
||||||
## 基础地址
|
**状态记忆**:API 服务和主动推送的状态及端口会自动保存,重启 WeFlow 后会自动恢复运行。
|
||||||
|
|
||||||
```
|
## 鉴权规范
|
||||||
http://127.0.0.1:5031
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
**鉴权规范 (Access Token)** 除健康检查接口外,所有 `/api/v1/*` 接口均受 Token 保护。支持三种传参方式(任选其一):
|
||||||
|
|
||||||
|
1. **HTTP Header (推荐)**: `Authorization: Bearer <您的Token>`
|
||||||
|
2. **Query 参数**: `?access_token=<您的Token>`(SSE 长连接推荐此方式)
|
||||||
|
3. **JSON Body**: `{"access_token": "<您的Token>"}`(仅限 POST 请求)
|
||||||
|
|
||||||
## 接口列表
|
## 接口列表
|
||||||
|
|
||||||
### 1. 健康检查
|
- `GET|POST /health`
|
||||||
|
- `GET|POST /api/v1/health`
|
||||||
|
- `GET|POST /api/v1/push/messages`
|
||||||
|
- `GET|POST /api/v1/messages`
|
||||||
|
- `GET|POST /api/v1/sessions`
|
||||||
|
- `GET /api/v1/sessions/:id/messages` (ChatLab Pull)
|
||||||
|
- `GET|POST /api/v1/contacts`
|
||||||
|
- `GET|POST /api/v1/group-members`
|
||||||
|
- `GET|POST /api/v1/media/*`
|
||||||
|
|
||||||
检查 API 服务是否正常运行。
|
---
|
||||||
|
|
||||||
|
## 1. 健康检查
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
```
|
|
||||||
|
```http
|
||||||
GET /health
|
GET /health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
或
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
**响应**
|
**响应**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "ok"
|
"status": "ok"
|
||||||
@@ -36,158 +59,228 @@ GET /health
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. 获取消息列表
|
## 2. 主动推送
|
||||||
|
|
||||||
获取指定会话的消息,支持 ChatLab 格式输出。
|
通过 SSE 长连接接收新消息事件,端口与 HTTP API 共用。
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/push/messages
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 说明
|
||||||
|
|
||||||
|
- 需要先在设置页开启 `HTTP API 服务`
|
||||||
|
- 同时需要开启 `主动推送`
|
||||||
|
- 响应类型为 `text/event-stream`
|
||||||
|
- 新消息事件名固定为 `message.new`
|
||||||
|
- 建议接收端按 `messageKey` 去重
|
||||||
|
|
||||||
|
### 事件字段
|
||||||
|
|
||||||
|
- `event`
|
||||||
|
- `sessionId`
|
||||||
|
- `messageKey`
|
||||||
|
- `avatarUrl`
|
||||||
|
- `sourceName`
|
||||||
|
- `groupName`(仅群聊)
|
||||||
|
- `content`
|
||||||
|
- `timestamp`(消息时间,秒级 Unix 时间戳)
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```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":"[图片]","timestamp":1760000123}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 获取消息
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
|
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
GET /api/v1/messages
|
GET /api/v1/messages
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数**
|
### 参数
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
| --------- | ------ | ---- | ----------------------------------------------------- |
|
||||||
| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) |
|
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
|
||||||
| `limit` | number | ❌ | 返回数量限制,默认 100,范围 `1~10000` |
|
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
|
||||||
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
|
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||||
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
|
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 |
|
||||||
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
|
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 |
|
||||||
| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) |
|
| `keyword` | string | 否 | 基于消息显示文本过滤 |
|
||||||
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
|
| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 |
|
||||||
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
|
| `format` | string | 否 | `json` 或 `chatlab` |
|
||||||
| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti`);`0` 时媒体返回占位符 |
|
| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
|
||||||
| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian`) |
|
| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
|
||||||
| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce`) |
|
| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
|
||||||
| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` |
|
| `video` | string | 否 | 在 `media=1` 时控制视频导出 |
|
||||||
| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` |
|
| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 |
|
||||||
|
|
||||||
默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media`
|
### 示例
|
||||||
|
|
||||||
**示例请求**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 获取消息(原始格式)
|
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=20"
|
||||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50
|
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"
|
||||||
# 获取消息(ChatLab 格式)
|
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&voice=0&video=0&emoji=0"
|
||||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1
|
|
||||||
|
|
||||||
# 带时间范围查询
|
|
||||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100
|
|
||||||
|
|
||||||
# 开启媒体导出(只导出图片和语音)
|
|
||||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&media=1&image=1&voice=1&video=0&emoji=0
|
|
||||||
|
|
||||||
# 关键词过滤
|
|
||||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&limit=50
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应(原始格式)**
|
### 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
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"talker": "wxid_xxx",
|
"talker": "xxx@chatroom",
|
||||||
"count": 50,
|
"count": 2,
|
||||||
"hasMore": true,
|
"hasMore": true,
|
||||||
"media": {
|
"media": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||||
"count": 12
|
"count": 1
|
||||||
},
|
},
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"localId": 123,
|
"localId": 123,
|
||||||
|
"serverId": "456",
|
||||||
|
"localType": 1,
|
||||||
|
"createTime": 1738713600,
|
||||||
|
"isSend": 0,
|
||||||
|
"senderUsername": "wxid_member",
|
||||||
|
"content": "你好",
|
||||||
|
"rawContent": "你好",
|
||||||
|
"parsedContent": "你好"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"localId": 124,
|
||||||
"localType": 3,
|
"localType": 3,
|
||||||
|
"createTime": 1738713660,
|
||||||
|
"isSend": 0,
|
||||||
|
"senderUsername": "wxid_member",
|
||||||
"content": "[图片]",
|
"content": "[图片]",
|
||||||
"createTime": 1738713600000,
|
|
||||||
"senderUsername": "wxid_sender",
|
|
||||||
"mediaType": "image",
|
"mediaType": "image",
|
||||||
"mediaFileName": "image_123.jpg",
|
"mediaFileName": "abc123.jpg",
|
||||||
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.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 响应
|
||||||
```json
|
|
||||||
{
|
当 `chatlab=1` 或 `format=chatlab` 时,返回 ChatLab 结构:
|
||||||
"chatlab": {
|
|
||||||
"version": "0.0.2",
|
- `chatlab.version`
|
||||||
"exportedAt": 1738713600000,
|
- `chatlab.exportedAt`
|
||||||
"generator": "WeFlow",
|
- `chatlab.generator`
|
||||||
"description": "Exported from WeFlow"
|
- `meta.name`
|
||||||
},
|
- `meta.platform`
|
||||||
"meta": {
|
- `meta.type`
|
||||||
"name": "会话名称",
|
- `meta.groupId`
|
||||||
"platform": "wechat",
|
- `meta.groupAvatar`
|
||||||
"type": "private",
|
- `meta.ownerId`
|
||||||
"ownerId": "wxid_me"
|
- `members[].platformId`
|
||||||
},
|
- `members[].accountName`
|
||||||
"members": [
|
- `members[].groupNickname`
|
||||||
{
|
- `members[].avatar`
|
||||||
"platformId": "wxid_xxx",
|
- `messages[].sender`
|
||||||
"accountName": "用户名",
|
- `messages[].accountName`
|
||||||
"groupNickname": "群昵称"
|
- `messages[].groupNickname`
|
||||||
}
|
- `messages[].timestamp`
|
||||||
],
|
- `messages[].type`
|
||||||
"messages": [
|
- `messages[].content`
|
||||||
{
|
- `messages[].platformMessageId`
|
||||||
"sender": "wxid_xxx",
|
- `messages[].mediaPath`
|
||||||
"accountName": "用户名",
|
|
||||||
"timestamp": 1738713600000,
|
群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
|
||||||
"type": 0,
|
|
||||||
"content": "消息内容",
|
|
||||||
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"media": {
|
|
||||||
"enabled": true,
|
|
||||||
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
|
||||||
"count": 12
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. 获取会话列表
|
## 4. 获取会话列表
|
||||||
|
|
||||||
获取所有会话列表。
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
```
|
|
||||||
|
```http
|
||||||
GET /api/v1/sessions
|
GET /api/v1/sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数**
|
### 参数
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
| --------- | ------ | ---- | -------------------------------- |
|
||||||
| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID |
|
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
| `limit` | number | 否 | 默认 `100` |
|
||||||
|
|
||||||
**示例请求**
|
### 响应字段
|
||||||
```bash
|
|
||||||
GET http://127.0.0.1:5031/api/v1/sessions
|
|
||||||
|
|
||||||
GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
|
- `success`
|
||||||
```
|
- `count`
|
||||||
|
- `sessions[].username`
|
||||||
|
- `sessions[].displayName`
|
||||||
|
- `sessions[].type`
|
||||||
|
- `sessions[].lastTimestamp`
|
||||||
|
- `sessions[].unreadCount`
|
||||||
|
|
||||||
|
**示例响应**
|
||||||
|
|
||||||
**响应**
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"count": 50,
|
"count": 1,
|
||||||
"total": 100,
|
|
||||||
"sessions": [
|
"sessions": [
|
||||||
{
|
{
|
||||||
"username": "wxid_xxx",
|
"username": "xxx@chatroom",
|
||||||
"displayName": "用户名",
|
"displayName": "项目群",
|
||||||
"lastMessage": "最后一条消息",
|
"type": 2,
|
||||||
"lastTime": 1738713600000,
|
"lastTimestamp": 1738713600,
|
||||||
"unreadCount": 0
|
"unreadCount": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -196,40 +289,174 @@ GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. 获取联系人列表
|
## 4.1 获取会话列表(ChatLab 格式)
|
||||||
|
|
||||||
获取所有联系人信息。
|
当 `format=chatlab` 时,返回 ChatLab Pull 协议兼容格式,可直接作为 ChatLab 远程数据源。
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sessions?format=chatlab
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --------- | ------ | ---- | -------------------------------- |
|
||||||
|
| `format` | string | 是 | 设为 `chatlab` |
|
||||||
|
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||||
|
| `limit` | number | 否 | 默认 `100` |
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"id": "xxx@chatroom",
|
||||||
|
"name": "项目群",
|
||||||
|
"platform": "wechat",
|
||||||
|
"type": "group",
|
||||||
|
"messageCount": 58000,
|
||||||
|
"lastMessageAt": 1738713600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --------------- | ----------------------------------- |
|
||||||
|
| `id` | 会话 ID(微信 username) |
|
||||||
|
| `name` | 会话显示名称 |
|
||||||
|
| `platform` | 固定 `wechat` |
|
||||||
|
| `type` | `group`(群聊)或 `private`(私聊) |
|
||||||
|
| `messageCount` | 消息数量(估算值,可能不精确) |
|
||||||
|
| `lastMessageAt` | 最后消息的秒级 Unix 时间戳 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.2 拉取会话消息(ChatLab Pull)
|
||||||
|
|
||||||
|
返回 ChatLab 标准格式的聊天数据,支持增量拉取和分页。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sessions/:id/messages
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| -------- | ------ | ---- | ---------------------------------------- |
|
||||||
|
| `:id` | string | 是 | 会话 ID(Path 参数) |
|
||||||
|
| `since` | number | 否 | 秒级 Unix 时间戳,仅返回此时间之后的消息 |
|
||||||
|
| `end` | number | 否 | 秒级 Unix 时间戳,时间上界 |
|
||||||
|
| `limit` | number | 否 | 单次返回上限,默认且最大 `5000` |
|
||||||
|
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
返回 ChatLab 标准 JSON 格式,外加 `sync` 分页块:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"chatlab": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"exportedAt": 1738713600,
|
||||||
|
"generator": "WeFlow"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"name": "项目群",
|
||||||
|
"platform": "wechat",
|
||||||
|
"type": "group",
|
||||||
|
"groupId": "xxx@chatroom",
|
||||||
|
"ownerId": "wxid_xxx"
|
||||||
|
},
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"platformId": "wxid_a",
|
||||||
|
"accountName": "张三",
|
||||||
|
"groupNickname": "产品",
|
||||||
|
"avatar": "https://example.com/avatar.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"sender": "wxid_a",
|
||||||
|
"accountName": "张三",
|
||||||
|
"timestamp": 1738713600,
|
||||||
|
"type": 0,
|
||||||
|
"content": "你好",
|
||||||
|
"platformMessageId": "123456"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sync": {
|
||||||
|
"hasMore": true,
|
||||||
|
"nextSince": 1738713600,
|
||||||
|
"nextOffset": 5000,
|
||||||
|
"watermark": 1738714000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### sync 块
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| ------------ | -------------------------------- |
|
||||||
|
| `hasMore` | 是否还有更多数据 |
|
||||||
|
| `nextSince` | 下次请求的 `since` 值 |
|
||||||
|
| `nextOffset` | 下次请求的 `offset` 值 |
|
||||||
|
| `watermark` | 本次拉取的时间上界(秒级时间戳) |
|
||||||
|
|
||||||
|
**ChatLab 对接方式**:在 ChatLab 设置中添加远程数据源,`baseUrl` 填 `http://127.0.0.1:5031/api/v1`,Token 填 WeFlow 中配置的 API Token。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 获取联系人列表
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
GET /api/v1/contacts
|
GET /api/v1/contacts
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数**
|
### 参数
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
| --------- | ------ | ---- | ---------------------------------------------------- |
|
||||||
| `keyword` | string | ❌ | 搜索关键词 |
|
| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
|
||||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
| `limit` | number | 否 | 默认 `100` |
|
||||||
|
|
||||||
**示例请求**
|
### 响应字段
|
||||||
```bash
|
|
||||||
GET http://127.0.0.1:5031/api/v1/contacts
|
|
||||||
|
|
||||||
GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三
|
- `success`
|
||||||
```
|
- `count`
|
||||||
|
- `contacts[].username`
|
||||||
|
- `contacts[].displayName`
|
||||||
|
- `contacts[].remark`
|
||||||
|
- `contacts[].nickname`
|
||||||
|
- `contacts[].alias`
|
||||||
|
- `contacts[].avatarUrl`
|
||||||
|
- `contacts[].type`
|
||||||
|
|
||||||
|
**示例响应**
|
||||||
|
|
||||||
**响应**
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"count": 50,
|
"count": 1,
|
||||||
"contacts": [
|
"contacts": [
|
||||||
{
|
{
|
||||||
"userName": "wxid_xxx",
|
"username": "wxid_xxx",
|
||||||
"alias": "微信号",
|
"displayName": "张三",
|
||||||
"nickName": "昵称",
|
"remark": "客户张三",
|
||||||
"remark": "备注名"
|
"nickname": "张三",
|
||||||
|
"alias": "zhangsan",
|
||||||
|
"avatarUrl": "https://example.com/avatar.jpg",
|
||||||
|
"type": "friend"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -237,60 +464,281 @@ GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ChatLab 格式说明
|
## 6. 获取群成员列表
|
||||||
|
|
||||||
ChatLab 是一种标准化的聊天记录交换格式,版本 0.0.2。
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
### 消息类型映射
|
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
|
||||||
|
|
||||||
| ChatLab Type | 值 | 说明 |
|
**请求**
|
||||||
|--------------|-----|------|
|
|
||||||
| TEXT | 0 | 文本消息 |
|
```http
|
||||||
| IMAGE | 1 | 图片 |
|
GET /api/v1/group-members
|
||||||
| VOICE | 2 | 语音 |
|
```
|
||||||
| VIDEO | 3 | 视频 |
|
|
||||||
| FILE | 4 | 文件 |
|
### 参数
|
||||||
| EMOJI | 5 | 表情 |
|
|
||||||
| LINK | 7 | 链接 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
| LOCATION | 8 | 位置 |
|
| ---------------------- | ------ | ---- | ------------------------------- |
|
||||||
| RED_PACKET | 20 | 红包 |
|
| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
|
||||||
| TRANSFER | 21 | 转账 |
|
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
|
||||||
| CALL | 23 | 通话 |
|
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
|
||||||
| SYSTEM | 80 | 系统消息 |
|
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
|
||||||
| RECALL | 81 | 撤回消息 |
|
|
||||||
| OTHER | 99 | 其他 |
|
### 响应字段
|
||||||
|
|
||||||
|
- `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
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 健康检查
|
$headers = @{ "Authorization" = "Bearer YOUR_TOKEN" }
|
||||||
Invoke-RestMethod http://127.0.0.1:5031/health
|
$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"
|
||||||
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
|
|
||||||
|
|
||||||
# 获取消息
|
|
||||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
|
||||||
|
|
||||||
# 获取 ChatLab 格式
|
|
||||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### cURL
|
### cURL
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 健康检查
|
# GET 带 Token Header
|
||||||
curl http://127.0.0.1:5031/health
|
curl -H "Authorization: Bearer YOUR_TOKEN" "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx"
|
||||||
|
|
||||||
# 获取会话列表
|
# POST 带 JSON Body
|
||||||
curl http://127.0.0.1:5031/api/v1/sessions
|
curl -X POST http://127.0.0.1:5031/api/v1/messages \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
# 获取消息(ChatLab 格式)
|
-H "Content-Type: application/json" \
|
||||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
|
-d '{"talker": "xxx@chatroom", "chatlab": true}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Python
|
### Python
|
||||||
@@ -299,40 +747,29 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
BASE_URL = "http://127.0.0.1:5031"
|
BASE_URL = "http://127.0.0.1:5031"
|
||||||
|
headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"}
|
||||||
|
|
||||||
# 获取会话列表
|
# POST 方式获取消息
|
||||||
sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json()
|
messages = requests.post(
|
||||||
print(sessions)
|
f"{BASE_URL}/api/v1/messages",
|
||||||
|
json={"talker": "xxx@chatroom", "limit": 50},
|
||||||
|
headers=headers
|
||||||
|
).json()
|
||||||
|
|
||||||
# 获取消息
|
# GET 方式获取群成员
|
||||||
messages = requests.get(f"{BASE_URL}/api/v1/messages", params={
|
members = requests.get(
|
||||||
"talker": "wxid_xxx",
|
f"{BASE_URL}/api/v1/group-members",
|
||||||
"limit": 100,
|
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1},
|
||||||
"chatlab": 1
|
headers=headers
|
||||||
}).json()
|
).json()
|
||||||
print(messages)
|
|
||||||
```
|
|
||||||
|
|
||||||
### JavaScript / Node.js
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const BASE_URL = "http://127.0.0.1:5031";
|
|
||||||
|
|
||||||
// 获取会话列表
|
|
||||||
const sessions = await fetch(`${BASE_URL}/api/v1/sessions`).then(r => r.json());
|
|
||||||
console.log(sessions);
|
|
||||||
|
|
||||||
// 获取消息(ChatLab 格式)
|
|
||||||
const messages = await fetch(`${BASE_URL}/api/v1/messages?talker=wxid_xxx&chatlab=1`)
|
|
||||||
.then(r => r.json());
|
|
||||||
console.log(messages);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 注意事项
|
## 10. 注意事项
|
||||||
|
|
||||||
1. API 仅监听本地地址 `127.0.0.1`,不对外网开放
|
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
||||||
2. 需要先连接数据库才能查询数据
|
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
||||||
3. 时间参数格式为 `YYYYMMDD`(如 20260205)
|
3. `start` 和 `end` 支持 `YYYYMMDD` 与时间戳;纯 `YYYYMMDD` 的 `end` 会扩展到当天 `23:59:59`。
|
||||||
4. 支持 CORS,可从浏览器前端直接调用
|
4. 群成员的 `groupNickname` 依赖微信源数据;源数据缺失时不会自动补出。
|
||||||
|
5. 媒体访问链接只有在对应消息已经通过 `media=1` 导出后才可访问。
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
base = stripped
|
||||||
}
|
}
|
||||||
return base
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|||||||
2401
electron/main.ts
2401
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
|
||||||
// 暴露给渲染进程的 API
|
// 暴露给渲染进程的 API
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
@@ -19,6 +19,11 @@ 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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -53,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),
|
||||||
@@ -63,24 +70,47 @@ 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')
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// 日志
|
// 日志
|
||||||
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: (options?: { mode?: 'add-account' }) => ipcRenderer.invoke('window:openOnboardingWindow', options),
|
||||||
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options),
|
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options),
|
||||||
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
|
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
|
||||||
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
||||||
@@ -89,7 +119,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
|
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
|
||||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
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)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 数据库路径
|
// 数据库路径
|
||||||
@@ -113,7 +157,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')
|
||||||
@@ -129,8 +174,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) =>
|
||||||
@@ -143,31 +194,88 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
|
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
|
||||||
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
|
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
|
||||||
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
|
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) =>
|
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
|
||||||
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
|
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),
|
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
||||||
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
||||||
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', 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),
|
||||||
|
getMyFootprintStats: (
|
||||||
|
beginTimestamp: number,
|
||||||
|
endTimestamp: number,
|
||||||
|
options?: {
|
||||||
|
myWxid?: string
|
||||||
|
privateSessionIds?: string[]
|
||||||
|
groupSessionIds?: string[]
|
||||||
|
mentionLimit?: number
|
||||||
|
privateLimit?: number
|
||||||
|
mentionMode?: 'text_at_me' | string
|
||||||
|
}
|
||||||
|
) => ipcRenderer.invoke('chat:getMyFootprintStats', beginTimestamp, endTimestamp, options),
|
||||||
|
exportMyFootprint: (
|
||||||
|
beginTimestamp: number,
|
||||||
|
endTimestamp: number,
|
||||||
|
format: 'csv' | 'json',
|
||||||
|
filePath: string
|
||||||
|
) => ipcRenderer.invoke('chat:exportMyFootprint', beginTimestamp, endTimestamp, format, filePath),
|
||||||
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)
|
||||||
@@ -178,25 +286,77 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// 图片解密
|
// 图片解密
|
||||||
image: {
|
image: {
|
||||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
decrypt: (payload: {
|
||||||
|
sessionId?: string
|
||||||
|
imageMd5?: string
|
||||||
|
imageDatName?: string
|
||||||
|
createTime?: number
|
||||||
|
force?: boolean
|
||||||
|
preferFilePath?: boolean
|
||||||
|
hardlinkOnly?: boolean
|
||||||
|
disableUpdateCheck?: boolean
|
||||||
|
allowCacheIndex?: boolean
|
||||||
|
suppressEvents?: 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
|
||||||
|
createTime?: number
|
||||||
|
preferFilePath?: boolean
|
||||||
|
hardlinkOnly?: boolean
|
||||||
|
disableUpdateCheck?: boolean
|
||||||
|
allowCacheIndex?: boolean
|
||||||
|
suppressEvents?: 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; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
|
||||||
|
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
|
||||||
|
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
|
||||||
|
preload: (
|
||||||
|
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
|
||||||
|
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||||
|
) => ipcRenderer.invoke('image:preload', payloads, options),
|
||||||
|
preloadHardlinkMd5s: (md5List: string[]) =>
|
||||||
|
ipcRenderer.invoke('image:preloadHardlinkMd5s', md5List),
|
||||||
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)
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -226,9 +386,19 @@ 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),
|
||||||
|
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),
|
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
|
||||||
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
||||||
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||||
@@ -237,9 +407,30 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// 年度报告
|
// 年度报告
|
||||||
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),
|
||||||
|
captureCurrentWindow: () => ipcRenderer.invoke('annualReport:captureCurrentWindow'),
|
||||||
|
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')
|
||||||
@@ -264,7 +455,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
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')
|
||||||
}
|
}
|
||||||
@@ -286,6 +490,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
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'),
|
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: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
||||||
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
|
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
|
||||||
@@ -294,13 +502,67 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
|
ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('sns:exportProgress')
|
return () => ipcRenderer.removeAllListeners('sns:exportProgress')
|
||||||
},
|
},
|
||||||
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir')
|
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 API 服务
|
||||||
http: {
|
http: {
|
||||||
start: (port?: number) => ipcRenderer.invoke('http:start', port),
|
start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host),
|
||||||
stop: () => ipcRenderer.invoke('http:stop'),
|
stop: () => ipcRenderer.invoke('http:stop'),
|
||||||
status: () => ipcRenderer.invoke('http:status')
|
status: () => ipcRenderer.invoke('http:status')
|
||||||
|
},
|
||||||
|
|
||||||
|
// AI 见解
|
||||||
|
insight: {
|
||||||
|
testConnection: () => ipcRenderer.invoke('insight:testConnection'),
|
||||||
|
getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'),
|
||||||
|
triggerTest: () => ipcRenderer.invoke('insight:triggerTest'),
|
||||||
|
generateFootprintInsight: (payload: {
|
||||||
|
rangeLabel: string
|
||||||
|
summary: {
|
||||||
|
private_inbound_people?: number
|
||||||
|
private_replied_people?: number
|
||||||
|
private_outbound_people?: number
|
||||||
|
private_reply_rate?: number
|
||||||
|
mention_count?: number
|
||||||
|
mention_group_count?: number
|
||||||
|
}
|
||||||
|
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
|
||||||
|
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
|
||||||
|
}) => ipcRenderer.invoke('insight:generateFootprintInsight', payload)
|
||||||
|
},
|
||||||
|
|
||||||
|
social: {
|
||||||
|
saveWeiboCookie: (rawInput: string) => ipcRenderer.invoke('social:saveWeiboCookie', rawInput),
|
||||||
|
validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -68,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)) {
|
||||||
// 使用参数化查询防止SQL注入
|
if (username && alias) map[username] = alias
|
||||||
const placeholders = chunk.map(() => '?').join(',')
|
|
||||||
const sql = `
|
|
||||||
SELECT username, alias
|
|
||||||
FROM contact
|
|
||||||
WHERE username IN (${placeholders})
|
|
||||||
`
|
|
||||||
const result = await wcdbService.execQuery('contact', null, sql, chunk)
|
|
||||||
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
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export interface AnnualReportData {
|
|||||||
initiatedChats: number
|
initiatedChats: number
|
||||||
receivedChats: number
|
receivedChats: number
|
||||||
initiativeRate: number
|
initiativeRate: number
|
||||||
|
topInitiatedFriend?: string
|
||||||
|
topInitiatedCount?: number
|
||||||
} | null
|
} | null
|
||||||
responseSpeed: {
|
responseSpeed: {
|
||||||
avgResponseTime: number
|
avgResponseTime: number
|
||||||
@@ -85,7 +87,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 +210,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) {
|
||||||
@@ -359,38 +617,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 sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
|
const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid)
|
||||||
if (sessionIds.length === 0) {
|
const cached = this.getCachedAvailableYears(cacheKey)
|
||||||
return { success: false, error: '未找到消息会话' }
|
if (cached) {
|
||||||
}
|
latestYears = cached
|
||||||
|
emitProgress({
|
||||||
const fastYears = await wcdbService.getAvailableYears(sessionIds)
|
years: cached,
|
||||||
if (fastYears.success && fastYears.data) {
|
strategy: 'cache',
|
||||||
return { success: true, data: fastYears.data }
|
phase: 'cache',
|
||||||
}
|
statusText: '命中缓存,已快速加载年份数据'
|
||||||
|
})
|
||||||
const years = new Set<number>()
|
return {
|
||||||
for (const sessionId of sessionIds) {
|
success: true,
|
||||||
const first = await this.getEdgeMessageTime(sessionId, true)
|
data: cached,
|
||||||
const last = await this.getEdgeMessageTime(sessionId, false)
|
meta: buildMeta('cache', '命中缓存,已快速加载年份数据')
|
||||||
if (!first && !last) continue
|
|
||||||
|
|
||||||
const minYear = new Date((first || last || 0) * 1000).getFullYear()
|
|
||||||
const maxYear = new Date((last || first || 0) * 1000).getFullYear()
|
|
||||||
for (let y = minYear; y <= maxYear; y++) {
|
|
||||||
if (y >= 2010 && y <= new Date().getFullYear()) years.add(y)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedYears = Array.from(years).sort((a, b) => b - a)
|
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
|
||||||
return { success: true, data: sortedYears }
|
if (sessionIds.length === 0) {
|
||||||
|
return { success: false, error: '未找到消息会话', meta: buildMeta('hybrid', '未找到消息会话') }
|
||||||
|
}
|
||||||
|
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||||
|
|
||||||
|
const nativeTimeoutMs = Math.max(1000, Math.floor(params.nativeTimeoutMs || 5000))
|
||||||
|
const nativeStartedAt = Date.now()
|
||||||
|
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)
|
||||||
|
|
||||||
|
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||||
|
|
||||||
|
if (nativeRace.kind === 'result' && nativeRace.result.success && Array.isArray(nativeRace.result.data) && nativeRace.result.data.length > 0) {
|
||||||
|
const years = this.normalizeAvailableYears(nativeRace.result.data)
|
||||||
|
latestYears = years
|
||||||
|
this.setCachedAvailableYears(cacheKey, years)
|
||||||
|
emitProgress({
|
||||||
|
years,
|
||||||
|
strategy: 'native',
|
||||||
|
phase: 'native',
|
||||||
|
statusText: '原生快速模式加载完成'
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: years,
|
||||||
|
meta: buildMeta('native', '原生快速模式加载完成')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switched = true
|
||||||
|
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: '加载年度数据失败' } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,7 +1137,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)
|
||||||
@@ -746,7 +1192,9 @@ class AnnualReportService {
|
|||||||
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
} | undefined
|
} | undefined
|
||||||
|
|
||||||
const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime)
|
const snsBeginTime = isAllTime ? 0 : actualStartTime
|
||||||
|
const snsEndTime = isAllTime ? Math.floor(Date.now() / 1000) : actualEndTime
|
||||||
|
const snsStats = await wcdbService.getSnsAnnualStats(snsBeginTime, snsEndTime)
|
||||||
|
|
||||||
if (snsStats.success && snsStats.data) {
|
if (snsStats.success && snsStats.data) {
|
||||||
const d = snsStats.data
|
const d = snsStats.data
|
||||||
@@ -773,6 +1221,20 @@ class AnnualReportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ALL YEARS 兼容:部分底层实现 begin/end 为 0 时会返回 0,兜底使用导出统计总数。
|
||||||
|
if (isAllTime && (!snsStatsResult || Number(snsStatsResult.totalPosts || 0) <= 0)) {
|
||||||
|
const snsExportStats = await wcdbService.getSnsExportStats(cleanedWxid || rawWxid)
|
||||||
|
if (snsExportStats.success && snsExportStats.data) {
|
||||||
|
const fallbackTotalPosts = Math.max(0, Number(snsExportStats.data.totalPosts || 0))
|
||||||
|
snsStatsResult = {
|
||||||
|
totalPosts: fallbackTotalPosts,
|
||||||
|
typeCounts: snsStatsResult?.typeCounts,
|
||||||
|
topLikers: snsStatsResult?.topLikers || [],
|
||||||
|
topLiked: snsStatsResult?.topLiked || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.reportProgress('整理联系人信息...', 85, onProgress)
|
this.reportProgress('整理联系人信息...', 85, onProgress)
|
||||||
|
|
||||||
const contactIds = Array.from(contactStats.keys())
|
const contactIds = Array.from(contactStats.keys())
|
||||||
@@ -902,16 +1364,27 @@ class AnnualReportService {
|
|||||||
let socialInitiative: AnnualReportData['socialInitiative'] = null
|
let socialInitiative: AnnualReportData['socialInitiative'] = null
|
||||||
let totalInitiated = 0
|
let totalInitiated = 0
|
||||||
let totalReceived = 0
|
let totalReceived = 0
|
||||||
for (const stats of conversationStarts.values()) {
|
let topInitiatedSessionId = ''
|
||||||
|
let topInitiatedCount = 0
|
||||||
|
for (const [sessionId, stats] of conversationStarts.entries()) {
|
||||||
totalInitiated += stats.initiated
|
totalInitiated += stats.initiated
|
||||||
totalReceived += stats.received
|
totalReceived += stats.received
|
||||||
|
if (stats.initiated > topInitiatedCount) {
|
||||||
|
topInitiatedCount = stats.initiated
|
||||||
|
topInitiatedSessionId = sessionId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const totalConversations = totalInitiated + totalReceived
|
const totalConversations = totalInitiated + totalReceived
|
||||||
if (totalConversations > 0) {
|
if (totalConversations > 0) {
|
||||||
|
const topInitiatedInfo = topInitiatedSessionId ? contactInfoMap.get(topInitiatedSessionId) : null
|
||||||
socialInitiative = {
|
socialInitiative = {
|
||||||
initiatedChats: totalInitiated,
|
initiatedChats: totalInitiated,
|
||||||
receivedChats: totalReceived,
|
receivedChats: totalReceived,
|
||||||
initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10
|
initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10,
|
||||||
|
topInitiatedFriend: topInitiatedCount > 0
|
||||||
|
? (topInitiatedInfo?.displayName || topInitiatedSessionId)
|
||||||
|
: undefined,
|
||||||
|
topInitiatedCount: topInitiatedCount > 0 ? topInitiatedCount : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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();
|
||||||
250
electron/services/bizService.ts
Normal file
250
electron/services/bizService.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
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
|
||||||
|
unread_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
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> = {}
|
||||||
|
const bizUnreadCount: Record<string, number> = {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionsRes = await chatService.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.lastTimestamp || session.sortTimestamp || 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
|
||||||
|
}
|
||||||
|
if (usernames.includes(uname)) {
|
||||||
|
const unread = Number(session.unreadCount ?? session.unread_count ?? 0)
|
||||||
|
bizUnreadCount[uname] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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),
|
||||||
|
unread_count: bizUnreadCount[uname] || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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
247
electron/services/cloudControlService.ts
Normal file
247
electron/services/cloudControlService.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
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
|
||||||
|
if (wcdbService.isReady()) {
|
||||||
|
try {
|
||||||
|
await wcdbService.cloudStop()
|
||||||
|
} catch {
|
||||||
|
// 忽略停止失败,避免阻塞主进程退出
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs() {
|
||||||
|
return wcdbService.getLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cloudControlService = new CloudControlService()
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { app, safeStorage } from 'electron'
|
import { app, safeStorage } from 'electron'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
|
import { expandHomePath } from '../utils/pathUtils'
|
||||||
|
|
||||||
// 加密前缀标记
|
// 加密前缀标记
|
||||||
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||||
|
const isSafeStorageAvailable = (): boolean => {
|
||||||
|
try {
|
||||||
|
return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
||||||
|
|
||||||
interface ConfigSchema {
|
interface ConfigSchema {
|
||||||
@@ -16,7 +24,7 @@ interface ConfigSchema {
|
|||||||
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
|
||||||
@@ -27,6 +35,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
|
||||||
@@ -44,17 +53,74 @@ interface ConfigSchema {
|
|||||||
|
|
||||||
// 更新相关
|
// 更新相关
|
||||||
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
|
||||||
|
messagePushFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
|
messagePushFilterList: string[]
|
||||||
|
httpApiEnabled: boolean
|
||||||
|
httpApiPort: number
|
||||||
|
httpApiHost: string
|
||||||
|
httpApiToken: string
|
||||||
|
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||||
|
quoteLayout: 'quote-top' | 'quote-bottom'
|
||||||
wordCloudExcludeWords: string[]
|
wordCloudExcludeWords: string[]
|
||||||
|
exportWriteLayout: 'A' | 'B' | 'C'
|
||||||
|
exportAutomationTaskMap: Record<string, unknown>
|
||||||
|
|
||||||
|
// AI 见解
|
||||||
|
aiModelApiBaseUrl: string
|
||||||
|
aiModelApiKey: string
|
||||||
|
aiModelApiModel: string
|
||||||
|
aiModelApiMaxTokens: number
|
||||||
|
aiInsightEnabled: boolean
|
||||||
|
aiInsightApiBaseUrl: string
|
||||||
|
aiInsightApiKey: string
|
||||||
|
aiInsightApiModel: string
|
||||||
|
aiInsightSilenceDays: number
|
||||||
|
aiInsightAllowContext: boolean
|
||||||
|
aiInsightAllowSocialContext: boolean
|
||||||
|
aiInsightFilterMode: 'whitelist' | 'blacklist'
|
||||||
|
aiInsightFilterList: string[]
|
||||||
|
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
|
||||||
|
|
||||||
|
// AI 足迹
|
||||||
|
aiFootprintEnabled: boolean
|
||||||
|
aiFootprintSystemPrompt: string
|
||||||
|
/** 是否将 AI 见解调试日志输出到桌面 */
|
||||||
|
aiInsightDebugLogEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需要 safeStorage 加密的字段(普通模式)
|
// 需要 safeStorage 加密的字段(普通模式)
|
||||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
|
const ENCRYPTED_STRING_KEYS: Set<string> = new Set([
|
||||||
|
'decryptKey',
|
||||||
|
'imageAesKey',
|
||||||
|
'authPassword',
|
||||||
|
'httpApiToken',
|
||||||
|
'aiModelApiKey',
|
||||||
|
'aiInsightApiKey',
|
||||||
|
'aiInsightWeiboCookie'
|
||||||
|
])
|
||||||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||||
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||||
|
|
||||||
@@ -82,44 +148,111 @@ export class ConfigService {
|
|||||||
return ConfigService.instance
|
return ConfigService.instance
|
||||||
}
|
}
|
||||||
ConfigService.instance = this
|
ConfigService.instance = this
|
||||||
this.store = new Store<ConfigSchema>({
|
const defaults: ConfigSchema = {
|
||||||
|
dbPath: '',
|
||||||
|
decryptKey: '',
|
||||||
|
myWxid: '',
|
||||||
|
onboardingDone: false,
|
||||||
|
imageXorKey: 0,
|
||||||
|
imageAesKey: '',
|
||||||
|
wxidConfigs: {},
|
||||||
|
cachePath: '',
|
||||||
|
lastOpenedDb: '',
|
||||||
|
lastSession: '',
|
||||||
|
theme: 'system',
|
||||||
|
themeId: 'cloud-dancer',
|
||||||
|
language: 'zh-CN',
|
||||||
|
logEnabled: false,
|
||||||
|
llmModelPath: '',
|
||||||
|
whisperModelName: 'base',
|
||||||
|
whisperModelDir: '',
|
||||||
|
whisperDownloadSource: 'tsinghua',
|
||||||
|
autoTranscribeVoice: false,
|
||||||
|
transcribeLanguages: ['zh'],
|
||||||
|
exportDefaultConcurrency: 4,
|
||||||
|
analyticsExcludedUsernames: [],
|
||||||
|
authEnabled: false,
|
||||||
|
authPassword: '',
|
||||||
|
authUseHello: false,
|
||||||
|
authHelloSecret: '',
|
||||||
|
ignoredUpdateVersion: '',
|
||||||
|
updateChannel: 'auto',
|
||||||
|
notificationEnabled: true,
|
||||||
|
notificationPosition: 'top-right',
|
||||||
|
notificationFilterMode: 'all',
|
||||||
|
notificationFilterList: [],
|
||||||
|
httpApiToken: '',
|
||||||
|
httpApiEnabled: false,
|
||||||
|
httpApiPort: 5031,
|
||||||
|
httpApiHost: '127.0.0.1',
|
||||||
|
messagePushEnabled: false,
|
||||||
|
messagePushFilterMode: 'all',
|
||||||
|
messagePushFilterList: [],
|
||||||
|
windowCloseBehavior: 'ask',
|
||||||
|
quoteLayout: 'quote-top',
|
||||||
|
wordCloudExcludeWords: [],
|
||||||
|
exportWriteLayout: 'A',
|
||||||
|
exportAutomationTaskMap: {},
|
||||||
|
aiModelApiBaseUrl: '',
|
||||||
|
aiModelApiKey: '',
|
||||||
|
aiModelApiModel: 'gpt-4o-mini',
|
||||||
|
aiModelApiMaxTokens: 200,
|
||||||
|
aiInsightEnabled: false,
|
||||||
|
aiInsightApiBaseUrl: '',
|
||||||
|
aiInsightApiKey: '',
|
||||||
|
aiInsightApiModel: 'gpt-4o-mini',
|
||||||
|
aiInsightSilenceDays: 3,
|
||||||
|
aiInsightAllowContext: false,
|
||||||
|
aiInsightAllowSocialContext: false,
|
||||||
|
aiInsightFilterMode: 'whitelist',
|
||||||
|
aiInsightFilterList: [],
|
||||||
|
aiInsightWhitelistEnabled: false,
|
||||||
|
aiInsightWhitelist: [],
|
||||||
|
aiInsightCooldownMinutes: 120,
|
||||||
|
aiInsightScanIntervalHours: 4,
|
||||||
|
aiInsightContextCount: 40,
|
||||||
|
aiInsightSocialContextCount: 3,
|
||||||
|
aiInsightSystemPrompt: '',
|
||||||
|
aiInsightTelegramEnabled: false,
|
||||||
|
aiInsightTelegramToken: '',
|
||||||
|
aiInsightTelegramChatIds: '',
|
||||||
|
aiInsightWeiboCookie: '',
|
||||||
|
aiInsightWeiboBindings: {},
|
||||||
|
aiFootprintEnabled: false,
|
||||||
|
aiFootprintSystemPrompt: '',
|
||||||
|
aiInsightDebugLogEnabled: false
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeOptions: any = {
|
||||||
name: 'WeFlow-config',
|
name: 'WeFlow-config',
|
||||||
defaults: {
|
defaults,
|
||||||
dbPath: '',
|
projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow'
|
||||||
decryptKey: '',
|
}
|
||||||
myWxid: '',
|
const runningInWorker = process.env.WEFLOW_WORKER === '1'
|
||||||
onboardingDone: false,
|
if (runningInWorker) {
|
||||||
imageXorKey: 0,
|
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
|
||||||
imageAesKey: '',
|
if (cwd) {
|
||||||
wxidConfigs: {},
|
storeOptions.cwd = cwd
|
||||||
cachePath: '',
|
|
||||||
lastOpenedDb: '',
|
|
||||||
lastSession: '',
|
|
||||||
theme: 'system',
|
|
||||||
themeId: 'cloud-dancer',
|
|
||||||
language: 'zh-CN',
|
|
||||||
logEnabled: false,
|
|
||||||
llmModelPath: '',
|
|
||||||
whisperModelName: 'base',
|
|
||||||
whisperModelDir: '',
|
|
||||||
whisperDownloadSource: 'tsinghua',
|
|
||||||
autoTranscribeVoice: false,
|
|
||||||
transcribeLanguages: ['zh'],
|
|
||||||
exportDefaultConcurrency: 2,
|
|
||||||
analyticsExcludedUsernames: [],
|
|
||||||
authEnabled: false,
|
|
||||||
authPassword: '',
|
|
||||||
authUseHello: false,
|
|
||||||
authHelloSecret: '',
|
|
||||||
ignoredUpdateVersion: '',
|
|
||||||
notificationEnabled: true,
|
|
||||||
notificationPosition: 'top-right',
|
|
||||||
notificationFilterMode: 'all',
|
|
||||||
notificationFilterList: [],
|
|
||||||
wordCloudExcludeWords: []
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
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()
|
this.migrateAuthFields()
|
||||||
|
this.migrateAiConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 状态查询 ===
|
// === 状态查询 ===
|
||||||
@@ -169,6 +302,10 @@ export class ConfigService {
|
|||||||
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
|
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'dbPath' && typeof raw === 'string') {
|
||||||
|
return expandHomePath(raw) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,8 +313,14 @@ export class ConfigService {
|
|||||||
let toStore = value
|
let toStore = value
|
||||||
const inLockMode = this.isLockMode() && this.unlockPassword
|
const inLockMode = this.isLockMode() && this.unlockPassword
|
||||||
|
|
||||||
|
if (key === 'dbPath' && typeof value === 'string') {
|
||||||
|
toStore = expandHomePath(value) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
|
||||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||||
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
const boolValue = value === true || value === 'true'
|
||||||
|
// `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗
|
||||||
|
toStore = (boolValue ? this.safeEncrypt('true') : false) as ConfigSchema[K]
|
||||||
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||||
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
||||||
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
||||||
@@ -210,7 +353,7 @@ export class ConfigService {
|
|||||||
private safeEncrypt(plaintext: string): string {
|
private safeEncrypt(plaintext: string): string {
|
||||||
if (!plaintext) return ''
|
if (!plaintext) return ''
|
||||||
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
||||||
if (!safeStorage.isEncryptionAvailable()) return plaintext
|
if (!isSafeStorageAvailable()) return plaintext
|
||||||
const encrypted = safeStorage.encryptString(plaintext)
|
const encrypted = safeStorage.encryptString(plaintext)
|
||||||
return SAFE_PREFIX + encrypted.toString('base64')
|
return SAFE_PREFIX + encrypted.toString('base64')
|
||||||
}
|
}
|
||||||
@@ -218,7 +361,7 @@ export class ConfigService {
|
|||||||
private safeDecrypt(stored: string): string {
|
private safeDecrypt(stored: string): string {
|
||||||
if (!stored) return ''
|
if (!stored) return ''
|
||||||
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
||||||
if (!safeStorage.isEncryptionAvailable()) return ''
|
if (!isSafeStorageAvailable()) return ''
|
||||||
try {
|
try {
|
||||||
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
||||||
return safeStorage.decryptString(buf)
|
return safeStorage.decryptString(buf)
|
||||||
@@ -556,7 +699,7 @@ export class ConfigService {
|
|||||||
|
|
||||||
clearHelloSecret(): void {
|
clearHelloSecret(): void {
|
||||||
this.store.set('authHelloSecret', '' as any)
|
this.store.set('authHelloSecret', '' as any)
|
||||||
this.store.set('authUseHello', this.safeEncrypt('false') as any)
|
this.store.set('authUseHello', false as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 迁移 ===
|
// === 迁移 ===
|
||||||
@@ -565,13 +708,18 @@ export class ConfigService {
|
|||||||
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
|
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
|
||||||
// 如果已经是 safe: 或 lock: 前缀则跳过
|
// 如果已经是 safe: 或 lock: 前缀则跳过
|
||||||
const rawEnabled: any = this.store.get('authEnabled')
|
const rawEnabled: any = this.store.get('authEnabled')
|
||||||
if (typeof rawEnabled === 'boolean') {
|
if (rawEnabled === true || rawEnabled === 'true') {
|
||||||
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any)
|
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||||||
|
} else if (rawEnabled === false || rawEnabled === 'false') {
|
||||||
|
// 保持 false 为明文布尔,避免冷启动访问 keychain
|
||||||
|
this.store.set('authEnabled', false as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawUseHello: any = this.store.get('authUseHello')
|
const rawUseHello: any = this.store.get('authUseHello')
|
||||||
if (typeof rawUseHello === 'boolean') {
|
if (rawUseHello === true || rawUseHello === 'true') {
|
||||||
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any)
|
this.store.set('authUseHello', this.safeEncrypt('true') as any)
|
||||||
|
} else if (rawUseHello === false || rawUseHello === 'false') {
|
||||||
|
this.store.set('authUseHello', false as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawPassword: any = this.store.get('authPassword')
|
const rawPassword: any = this.store.get('authPassword')
|
||||||
@@ -617,6 +765,26 @@ export class ConfigService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private migrateAiConfig(): void {
|
||||||
|
const sharedBaseUrl = String(this.get('aiModelApiBaseUrl') || '').trim()
|
||||||
|
const sharedApiKey = String(this.get('aiModelApiKey') || '').trim()
|
||||||
|
const sharedModel = String(this.get('aiModelApiModel') || '').trim()
|
||||||
|
|
||||||
|
const legacyBaseUrl = String(this.get('aiInsightApiBaseUrl') || '').trim()
|
||||||
|
const legacyApiKey = String(this.get('aiInsightApiKey') || '').trim()
|
||||||
|
const legacyModel = String(this.get('aiInsightApiModel') || '').trim()
|
||||||
|
|
||||||
|
if (!sharedBaseUrl && legacyBaseUrl) {
|
||||||
|
this.set('aiModelApiBaseUrl', legacyBaseUrl)
|
||||||
|
}
|
||||||
|
if (!sharedApiKey && legacyApiKey) {
|
||||||
|
this.set('aiModelApiKey', legacyApiKey)
|
||||||
|
}
|
||||||
|
if (!sharedModel && legacyModel) {
|
||||||
|
this.set('aiModelApiModel', legacyModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === 验证 ===
|
// === 验证 ===
|
||||||
|
|
||||||
verifyAuthEnabled(): boolean {
|
verifyAuthEnabled(): boolean {
|
||||||
@@ -628,17 +796,44 @@ export class ConfigService {
|
|||||||
|
|
||||||
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
||||||
const rawDecryptKey: any = this.store.get('decryptKey')
|
const rawDecryptKey: any = this.store.get('decryptKey')
|
||||||
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
|
return typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX);
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 工具方法 ===
|
// === 工具方法 ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局配置
|
||||||
|
*/
|
||||||
|
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 {
|
getCacheBasePath(): string {
|
||||||
return join(app.getPath('userData'), 'cache')
|
return join(this.getUserDataPath(), 'cache')
|
||||||
}
|
}
|
||||||
|
|
||||||
getAll(): Partial<ConfigSchema> {
|
getAll(): Partial<ConfigSchema> {
|
||||||
@@ -651,3 +846,4 @@ export class ConfigService {
|
|||||||
this.unlockPassword = null
|
this.unlockPassword = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,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
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -103,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)
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -137,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,91 @@
|
|||||||
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'
|
||||||
|
import { expandHomePath } from '../utils/pathUtils'
|
||||||
|
|
||||||
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 +94,39 @@ export class DbPathService {
|
|||||||
const possiblePaths: string[] = []
|
const possiblePaths: string[] = []
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
|
|
||||||
// 微信4.x 数据目录
|
if (process.platform === 'darwin') {
|
||||||
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
// 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'))
|
||||||
|
}
|
||||||
|
|
||||||
for (const path of possiblePaths) {
|
for (const path of possiblePaths) {
|
||||||
if (existsSync(path)) {
|
if (!existsSync(path)) continue
|
||||||
const rootName = path.split(/[/\\]/).pop()?.toLowerCase()
|
|
||||||
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
|
|
||||||
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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,13 +140,14 @@ export class DbPathService {
|
|||||||
* 查找账号目录(包含 db_storage 或图片目录)
|
* 查找账号目录(包含 db_storage 或图片目录)
|
||||||
*/
|
*/
|
||||||
findAccountDirs(rootPath: string): string[] {
|
findAccountDirs(rootPath: string): string[] {
|
||||||
|
const resolvedRootPath = expandHomePath(rootPath)
|
||||||
const accounts: string[] = []
|
const accounts: string[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = readdirSync(rootPath)
|
const entries = readdirSync(resolvedRootPath)
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryPath = join(rootPath, entry)
|
const entryPath = join(resolvedRootPath, entry)
|
||||||
let stat: ReturnType<typeof statSync>
|
let stat: ReturnType<typeof statSync>
|
||||||
try {
|
try {
|
||||||
stat = statSync(entryPath)
|
stat = statSync(entryPath)
|
||||||
@@ -122,70 +218,91 @@ export class DbPathService {
|
|||||||
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users)
|
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users)
|
||||||
*/
|
*/
|
||||||
scanWxidCandidates(rootPath: string): WxidInfo[] {
|
scanWxidCandidates(rootPath: string): WxidInfo[] {
|
||||||
|
const resolvedRootPath = expandHomePath(rootPath)
|
||||||
const wxids: WxidInfo[] = []
|
const wxids: WxidInfo[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (existsSync(rootPath)) {
|
if (existsSync(resolvedRootPath)) {
|
||||||
const entries = readdirSync(rootPath)
|
const entries = readdirSync(resolvedRootPath)
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryPath = join(rootPath, entry)
|
const entryPath = join(resolvedRootPath, 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(resolvedRootPath)
|
||||||
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||||
const rootStat = statSync(rootPath)
|
const rootStat = statSync(resolvedRootPath)
|
||||||
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
|
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} 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(resolvedRootPath);
|
||||||
|
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 列表
|
||||||
*/
|
*/
|
||||||
scanWxids(rootPath: string): WxidInfo[] {
|
scanWxids(rootPath: string): WxidInfo[] {
|
||||||
|
const resolvedRootPath = expandHomePath(rootPath)
|
||||||
const wxids: WxidInfo[] = []
|
const wxids: WxidInfo[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.isAccountDir(rootPath)) {
|
if (this.isAccountDir(resolvedRootPath)) {
|
||||||
const wxid = basename(rootPath)
|
const wxid = basename(resolvedRootPath)
|
||||||
const modifiedTime = this.getAccountModifiedTime(rootPath)
|
const modifiedTime = this.getAccountModifiedTime(resolvedRootPath)
|
||||||
return [{ wxid, modifiedTime }]
|
return [{ wxid, modifiedTime }]
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = this.findAccountDirs(rootPath)
|
const accounts = this.findAccountDirs(resolvedRootPath)
|
||||||
|
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
const fullPath = join(rootPath, account)
|
const fullPath = join(resolvedRootPath, account)
|
||||||
const modifiedTime = this.getAccountModifiedTime(fullPath)
|
const modifiedTime = this.getAccountModifiedTime(fullPath)
|
||||||
wxids.push({ wxid: account, modifiedTime })
|
wxids.push({ wxid: account, modifiedTime })
|
||||||
}
|
}
|
||||||
} 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(resolvedRootPath);
|
||||||
|
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 +310,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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -186,6 +186,33 @@ 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 {
|
.message-link-card {
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|||||||
@@ -186,6 +186,33 @@ 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 {
|
.message-link-card {
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|||||||
96
electron/services/exportRecordService.ts
Normal file
96
electron/services/exportRecordService.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
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 workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||||
|
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,38 +4,63 @@ type PreloadImagePayload = {
|
|||||||
sessionId?: string
|
sessionId?: string
|
||||||
imageMd5?: string
|
imageMd5?: string
|
||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
|
createTime?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
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,13 +74,25 @@ 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,
|
||||||
|
createTime: task.createTime,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
disableUpdateCheck: !task.allowDecrypt,
|
||||||
|
allowCacheIndex: task.allowCacheIndex,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
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,
|
||||||
imageDatName: task.imageDatName
|
imageDatName: task.imageDatName,
|
||||||
|
createTime: task.createTime,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
disableUpdateCheck: true,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// ignore preload failures
|
// ignore preload failures
|
||||||
|
|||||||
1309
electron/services/insightService.ts
Normal file
1309
electron/services/insightService.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
416
electron/services/keyServiceLinux.ts
Normal file
416
electron/services/keyServiceLinux.ts
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
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 crypto from 'crypto'
|
||||||
|
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; verified?: boolean; 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',
|
||||||
|
'/usr/local/bin/wechat',
|
||||||
|
'/usr/bin/wechat',
|
||||||
|
'/opt/apps/com.tencent.wechat/files/wechat',
|
||||||
|
'/usr/bin/wechat-bin',
|
||||||
|
'/usr/local/bin/wechat-bin',
|
||||||
|
'com.tencent.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]
|
||||||
|
const aesKey = String(keyObj.aesKey || '')
|
||||||
|
const verified = await this.verifyImageKeyByTemplate(accountPath, aesKey)
|
||||||
|
if (verified === true) {
|
||||||
|
onProgress?.('缓存密钥校验成功,已确认可用')
|
||||||
|
} else if (verified === false) {
|
||||||
|
onProgress?.('已从缓存计算密钥,但未通过本地模板校验')
|
||||||
|
}
|
||||||
|
return { success: true, xorKey: keyObj.xorKey, aesKey, verified: verified === true }
|
||||||
|
}
|
||||||
|
return { success: false, error: '未在缓存中找到匹配的图片密钥' }
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyImageKeyByTemplate(accountPath: string | undefined, aesKey: string): Promise<boolean | null> {
|
||||||
|
const normalizedPath = String(accountPath || '').trim()
|
||||||
|
if (!normalizedPath || !aesKey || aesKey.length < 16 || !existsSync(normalizedPath)) return null
|
||||||
|
try {
|
||||||
|
const template = await this._findTemplateData(normalizedPath, 32)
|
||||||
|
if (!template.ciphertext) return null
|
||||||
|
return this.verifyDerivedAesKey(aesKey, template.ciphertext)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
|
||||||
|
try {
|
||||||
|
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
|
||||||
|
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null)
|
||||||
|
decipher.setAutoPadding(false)
|
||||||
|
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||||
|
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
|
||||||
|
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
|
||||||
|
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||||
|
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||||
|
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
1317
electron/services/keyServiceMac.ts
Normal file
1317
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");
|
||||||
|
}
|
||||||
572
electron/services/messagePushService.ts
Normal file
572
electron/services/messagePushService.ts
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
import { ConfigService } from './config'
|
||||||
|
import { chatService, type ChatSession, type Message } from './chatService'
|
||||||
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { httpService } from './httpService'
|
||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { pathToFileURL } from 'url'
|
||||||
|
|
||||||
|
interface SessionBaseline {
|
||||||
|
lastTimestamp: number
|
||||||
|
unreadCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessagePushPayload {
|
||||||
|
event: 'message.new'
|
||||||
|
sessionId: string
|
||||||
|
sessionType: 'private' | 'group' | 'official' | 'other'
|
||||||
|
messageKey: string
|
||||||
|
avatarUrl?: string
|
||||||
|
sourceName: string
|
||||||
|
groupName?: string
|
||||||
|
content: string | null
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const PUSH_CONFIG_KEYS = new Set([
|
||||||
|
'messagePushEnabled',
|
||||||
|
'messagePushFilterMode',
|
||||||
|
'messagePushFilterList',
|
||||||
|
'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 pushAvatarCacheDir: string
|
||||||
|
private readonly pushAvatarDataCache = new Map<string, string>()
|
||||||
|
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
|
||||||
|
private messageTableScanRequested = false
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.configService = ConfigService.getInstance()
|
||||||
|
this.pushAvatarCacheDir = path.join(this.configService.getCacheBasePath(), 'push-avatar-files')
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.started) return
|
||||||
|
this.started = true
|
||||||
|
void this.refreshConfiguration('startup')
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.started = false
|
||||||
|
this.processing = false
|
||||||
|
this.rerunRequested = false
|
||||||
|
this.resetRuntimeState()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
if (this.isSessionTableChange(tableName)) {
|
||||||
|
this.scheduleSync()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableName || this.isMessageTableChange(tableName)) {
|
||||||
|
this.scheduleSync({ scanMessageBackedSessions: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
this.messageTableScanRequested = 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(options: { scanMessageBackedSessions?: boolean } = {}): void {
|
||||||
|
if (options.scanMessageBackedSessions) {
|
||||||
|
this.messageTableScanRequested = true
|
||||||
|
}
|
||||||
|
|
||||||
|
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 scanMessageBackedSessions = this.messageTableScanRequested
|
||||||
|
this.messageTableScanRequested = false
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const previous = previousBaseline.get(session.username)
|
||||||
|
if (this.shouldInspectSession(previous, session)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return scanMessageBackedSessions && this.shouldScanMessageBackedSession(previous, session)
|
||||||
|
})
|
||||||
|
for (const session of candidates) {
|
||||||
|
await this.pushSessionMessages(
|
||||||
|
session,
|
||||||
|
previousBaseline.get(session.username) || this.sessionBaseline.get(session.username)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.processing = false
|
||||||
|
if (this.rerunRequested) {
|
||||||
|
this.rerunRequested = false
|
||||||
|
this.scheduleSync({ scanMessageBackedSessions: this.messageTableScanRequested })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setBaseline(sessions: ChatSession[]): void {
|
||||||
|
const previousBaseline = new Map(this.sessionBaseline)
|
||||||
|
const nextBaseline = new Map<string, SessionBaseline>()
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||||
|
this.sessionBaseline.clear()
|
||||||
|
for (const session of sessions) {
|
||||||
|
const username = String(session.username || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
const previous = previousBaseline.get(username)
|
||||||
|
const sessionTimestamp = Number(session.lastTimestamp || 0)
|
||||||
|
const initialTimestamp = sessionTimestamp > 0 ? sessionTimestamp : nowSeconds
|
||||||
|
nextBaseline.set(username, {
|
||||||
|
lastTimestamp: Math.max(sessionTimestamp, Number(previous?.lastTimestamp || 0), previous ? 0 : initialTimestamp),
|
||||||
|
unreadCount: Number(session.unreadCount || 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const [username, baseline] of nextBaseline.entries()) {
|
||||||
|
this.sessionBaseline.set(username, baseline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastTimestamp > previous.lastTimestamp || unreadCount > previous.unreadCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldScanMessageBackedSession(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 sessionType = this.getSessionType(sessionId, session)
|
||||||
|
if (sessionType === 'private') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(previous) || Number(session.lastTimestamp || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<void> {
|
||||||
|
const since = Math.max(0, Number(previous?.lastTimestamp || 0))
|
||||||
|
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
|
||||||
|
if (!this.shouldPushPayload(payload)) continue
|
||||||
|
|
||||||
|
httpService.broadcastMessagePush(payload)
|
||||||
|
this.rememberMessageKey(messageKey)
|
||||||
|
this.bumpSessionBaseline(session.username, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 sessionType = this.getSessionType(sessionId, session)
|
||||||
|
const content = this.getMessageDisplayContent(message)
|
||||||
|
|
||||||
|
const createTime = Number(message.createTime || 0)
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
const groupInfo = await chatService.getContactAvatar(sessionId)
|
||||||
|
const groupName = session.displayName || groupInfo?.displayName || sessionId
|
||||||
|
const sourceName = await this.resolveGroupSourceName(sessionId, message, session)
|
||||||
|
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || groupInfo?.avatarUrl)
|
||||||
|
return {
|
||||||
|
event: 'message.new',
|
||||||
|
sessionId,
|
||||||
|
sessionType,
|
||||||
|
messageKey,
|
||||||
|
avatarUrl,
|
||||||
|
groupName,
|
||||||
|
sourceName,
|
||||||
|
content,
|
||||||
|
timestamp: createTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactInfo = await chatService.getContactAvatar(sessionId)
|
||||||
|
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || contactInfo?.avatarUrl)
|
||||||
|
return {
|
||||||
|
event: 'message.new',
|
||||||
|
sessionId,
|
||||||
|
sessionType,
|
||||||
|
messageKey,
|
||||||
|
avatarUrl,
|
||||||
|
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
||||||
|
content,
|
||||||
|
timestamp: createTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async normalizePushAvatarUrl(avatarUrl?: string): Promise<string | undefined> {
|
||||||
|
const normalized = String(avatarUrl || '').trim()
|
||||||
|
if (!normalized) return undefined
|
||||||
|
if (!normalized.startsWith('data:image/')) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = this.pushAvatarDataCache.get(normalized)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(normalized)
|
||||||
|
if (!match) return undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mimeType = match[1].toLowerCase()
|
||||||
|
const base64Data = match[2]
|
||||||
|
const imageBuffer = Buffer.from(base64Data, 'base64')
|
||||||
|
if (!imageBuffer.length) return undefined
|
||||||
|
|
||||||
|
const ext = this.getImageExtFromMime(mimeType)
|
||||||
|
const hash = createHash('sha1').update(normalized).digest('hex')
|
||||||
|
const filePath = path.join(this.pushAvatarCacheDir, `avatar_${hash}.${ext}`)
|
||||||
|
|
||||||
|
await fs.mkdir(this.pushAvatarCacheDir, { recursive: true })
|
||||||
|
try {
|
||||||
|
await fs.access(filePath)
|
||||||
|
} catch {
|
||||||
|
await fs.writeFile(filePath, imageBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileUrl = pathToFileURL(filePath).toString()
|
||||||
|
this.pushAvatarDataCache.set(normalized, fileUrl)
|
||||||
|
return fileUrl
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getImageExtFromMime(mimeType: string): string {
|
||||||
|
if (mimeType === 'image/png') return 'png'
|
||||||
|
if (mimeType === 'image/gif') return 'gif'
|
||||||
|
if (mimeType === 'image/webp') return 'webp'
|
||||||
|
return 'jpg'
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] {
|
||||||
|
if (sessionId.endsWith('@chatroom')) {
|
||||||
|
return 'group'
|
||||||
|
}
|
||||||
|
if (sessionId.startsWith('gh_') || session.type === 'official') {
|
||||||
|
return 'official'
|
||||||
|
}
|
||||||
|
if (session.type === 'friend') {
|
||||||
|
return 'private'
|
||||||
|
}
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldPushPayload(payload: MessagePushPayload): boolean {
|
||||||
|
const sessionId = String(payload.sessionId || '').trim()
|
||||||
|
const filterMode = this.getMessagePushFilterMode()
|
||||||
|
if (filterMode === 'all') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterList = this.getMessagePushFilterList()
|
||||||
|
const listed = filterList.has(sessionId)
|
||||||
|
if (filterMode === 'whitelist') {
|
||||||
|
return listed
|
||||||
|
}
|
||||||
|
return !listed
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMessagePushFilterMode(): 'all' | 'whitelist' | 'blacklist' {
|
||||||
|
const value = this.configService.get('messagePushFilterMode')
|
||||||
|
if (value === 'whitelist' || value === 'blacklist') return value
|
||||||
|
return 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMessagePushFilterList(): Set<string> {
|
||||||
|
const value = this.configService.get('messagePushFilterList')
|
||||||
|
if (!Array.isArray(value)) return new Set()
|
||||||
|
return new Set(value.map((item) => String(item || '').trim()).filter(Boolean))
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSessionTableChange(tableName: string): boolean {
|
||||||
|
return String(tableName || '').trim().toLowerCase() === 'session'
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMessageTableChange(tableName: string): boolean {
|
||||||
|
const normalized = String(tableName || '').trim().toLowerCase()
|
||||||
|
if (!normalized) return false
|
||||||
|
return normalized === 'message' ||
|
||||||
|
normalized === 'msg' ||
|
||||||
|
normalized.startsWith('message_') ||
|
||||||
|
normalized.startsWith('msg_') ||
|
||||||
|
normalized.includes('message')
|
||||||
|
}
|
||||||
|
|
||||||
|
private bumpSessionBaseline(sessionId: string, message: Message): void {
|
||||||
|
const key = String(sessionId || '').trim()
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
const createTime = Number(message.createTime || 0)
|
||||||
|
if (!Number.isFinite(createTime) || createTime <= 0) return
|
||||||
|
|
||||||
|
const current = this.sessionBaseline.get(key) || { lastTimestamp: 0, unreadCount: 0 }
|
||||||
|
if (createTime > current.lastTimestamp) {
|
||||||
|
this.sessionBaseline.set(key, {
|
||||||
|
...current,
|
||||||
|
lastTimestamp: createTime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMessageDisplayContent(message: Message): string | null {
|
||||||
|
const cleanOfficialPrefix = (value: string | null): string | null => {
|
||||||
|
if (!value) return value
|
||||||
|
return value.replace(/^\s*\[视频号\]\s*/u, '').trim() || value
|
||||||
|
}
|
||||||
|
switch (Number(message.localType || 0)) {
|
||||||
|
case 1:
|
||||||
|
return cleanOfficialPrefix(message.rawContent || null)
|
||||||
|
case 3:
|
||||||
|
return '[图片]'
|
||||||
|
case 34:
|
||||||
|
return '[语音]'
|
||||||
|
case 43:
|
||||||
|
return '[视频]'
|
||||||
|
case 47:
|
||||||
|
return '[表情]'
|
||||||
|
case 42:
|
||||||
|
return cleanOfficialPrefix(message.cardNickname || '[名片]')
|
||||||
|
case 48:
|
||||||
|
return '[位置]'
|
||||||
|
case 49:
|
||||||
|
return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]')
|
||||||
|
default:
|
||||||
|
return cleanOfficialPrefix(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()
|
||||||
110
electron/services/nativeImageDecrypt.ts
Normal file
110
electron/services/nativeImageDecrypt.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
type NativeDecryptResult = {
|
||||||
|
data: Buffer
|
||||||
|
ext: string
|
||||||
|
isWxgf?: boolean
|
||||||
|
is_wxgf?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type NativeAddon = {
|
||||||
|
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedAddon: NativeAddon | null | undefined
|
||||||
|
|
||||||
|
function shouldEnableNative(): boolean {
|
||||||
|
return process.env.WEFLOW_IMAGE_NATIVE !== '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandAsarCandidates(filePath: string): string[] {
|
||||||
|
if (!filePath.includes('app.asar') || filePath.includes('app.asar.unpacked')) {
|
||||||
|
return [filePath]
|
||||||
|
}
|
||||||
|
return [filePath.replace('app.asar', 'app.asar.unpacked'), filePath]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatformDir(): string {
|
||||||
|
if (process.platform === 'win32') return 'win32'
|
||||||
|
if (process.platform === 'darwin') return 'macos'
|
||||||
|
if (process.platform === 'linux') return 'linux'
|
||||||
|
return process.platform
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArchDir(): string {
|
||||||
|
if (process.arch === 'x64') return 'x64'
|
||||||
|
if (process.arch === 'arm64') return 'arm64'
|
||||||
|
return process.arch
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAddonCandidates(): string[] {
|
||||||
|
const platformDir = getPlatformDir()
|
||||||
|
const archDir = getArchDir()
|
||||||
|
const cwd = process.cwd()
|
||||||
|
const fileNames = [
|
||||||
|
`weflow-image-native-${platformDir}-${archDir}.node`
|
||||||
|
]
|
||||||
|
const roots = [
|
||||||
|
join(cwd, 'resources', 'wedecrypt', platformDir, archDir),
|
||||||
|
...(process.resourcesPath
|
||||||
|
? [
|
||||||
|
join(process.resourcesPath, 'resources', 'wedecrypt', platformDir, archDir),
|
||||||
|
join(process.resourcesPath, 'wedecrypt', platformDir, archDir)
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
|
const candidates = roots.flatMap((root) => fileNames.map((name) => join(root, name)))
|
||||||
|
return Array.from(new Set(candidates.flatMap(expandAsarCandidates)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAddon(): NativeAddon | null {
|
||||||
|
if (!shouldEnableNative()) return null
|
||||||
|
if (cachedAddon !== undefined) return cachedAddon
|
||||||
|
|
||||||
|
for (const candidate of getAddonCandidates()) {
|
||||||
|
if (!existsSync(candidate)) continue
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const addon = require(candidate) as NativeAddon
|
||||||
|
if (addon && typeof addon.decryptDatNative === 'function') {
|
||||||
|
cachedAddon = addon
|
||||||
|
return addon
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// try next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedAddon = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nativeAddonLocation(): string | null {
|
||||||
|
for (const candidate of getAddonCandidates()) {
|
||||||
|
if (existsSync(candidate)) return candidate
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptDatViaNative(
|
||||||
|
inputPath: string,
|
||||||
|
xorKey: number,
|
||||||
|
aesKey?: string
|
||||||
|
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||||
|
const addon = loadAddon()
|
||||||
|
if (!addon) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = addon.decryptDatNative(inputPath, xorKey, aesKey)
|
||||||
|
const isWxgf = Boolean(result?.isWxgf ?? result?.is_wxgf)
|
||||||
|
if (!result || !Buffer.isBuffer(result.data)) return null
|
||||||
|
const rawExt = typeof result.ext === 'string' && result.ext.trim()
|
||||||
|
? result.ext.trim().toLowerCase()
|
||||||
|
: ''
|
||||||
|
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
|
||||||
|
return { data: result.data, ext, isWxgf }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
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
367
electron/services/social/weiboService.ts
Normal file
367
electron/services/social/weiboService.ts
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import https from 'https'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { URL } from 'url'
|
||||||
|
|
||||||
|
const WEIBO_TIMEOUT_MS = 10_000
|
||||||
|
const WEIBO_MAX_POSTS = 5
|
||||||
|
const WEIBO_CACHE_TTL_MS = 30 * 60 * 1000
|
||||||
|
const WEIBO_USER_AGENT =
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'
|
||||||
|
const WEIBO_MOBILE_USER_AGENT =
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
|
||||||
|
|
||||||
|
interface BrowserCookieEntry {
|
||||||
|
domain?: string
|
||||||
|
name?: string
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeiboUserInfo {
|
||||||
|
id?: number | string
|
||||||
|
screen_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeiboWaterFallItem {
|
||||||
|
id?: number | string
|
||||||
|
idstr?: string
|
||||||
|
mblogid?: string
|
||||||
|
created_at?: string
|
||||||
|
text_raw?: string
|
||||||
|
isLongText?: boolean
|
||||||
|
user?: WeiboUserInfo
|
||||||
|
retweeted_status?: WeiboWaterFallItem
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeiboWaterFallResponse {
|
||||||
|
ok?: number
|
||||||
|
data?: {
|
||||||
|
list?: WeiboWaterFallItem[]
|
||||||
|
next_cursor?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeiboStatusShowResponse {
|
||||||
|
id?: number | string
|
||||||
|
idstr?: string
|
||||||
|
mblogid?: string
|
||||||
|
created_at?: string
|
||||||
|
text_raw?: string
|
||||||
|
user?: WeiboUserInfo
|
||||||
|
retweeted_status?: WeiboWaterFallItem
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MWeiboCard {
|
||||||
|
mblog?: WeiboWaterFallItem
|
||||||
|
card_group?: MWeiboCard[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MWeiboContainerResponse {
|
||||||
|
ok?: number
|
||||||
|
data?: {
|
||||||
|
cards?: MWeiboCard[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeiboRecentPost {
|
||||||
|
id: string
|
||||||
|
createdAt: string
|
||||||
|
url: string
|
||||||
|
text: string
|
||||||
|
screenName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedRecentPosts {
|
||||||
|
expiresAt: number
|
||||||
|
posts: WeiboRecentPost[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestJson<T>(url: string, options: { cookie?: string; referer?: string; userAgent?: string }): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let urlObj: URL
|
||||||
|
try {
|
||||||
|
urlObj = new URL(url)
|
||||||
|
} catch {
|
||||||
|
reject(new Error(`无效的微博请求地址:${url}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json, text/plain, */*',
|
||||||
|
Referer: options.referer || 'https://weibo.com',
|
||||||
|
'User-Agent': options.userAgent || WEIBO_USER_AGENT,
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
if (options.cookie) {
|
||||||
|
headers.Cookie = options.cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = https.request(
|
||||||
|
{
|
||||||
|
hostname: urlObj.hostname,
|
||||||
|
port: urlObj.port || 443,
|
||||||
|
path: urlObj.pathname + urlObj.search,
|
||||||
|
method: 'GET',
|
||||||
|
headers
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let raw = ''
|
||||||
|
res.setEncoding('utf8')
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
raw += chunk
|
||||||
|
})
|
||||||
|
res.on('end', () => {
|
||||||
|
const statusCode = res.statusCode || 0
|
||||||
|
if (statusCode < 200 || statusCode >= 300) {
|
||||||
|
reject(new Error(`微博接口返回异常状态码 ${statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(raw) as T)
|
||||||
|
} catch {
|
||||||
|
reject(new Error('微博接口返回了非 JSON 响应'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
req.setTimeout(WEIBO_TIMEOUT_MS, () => {
|
||||||
|
req.destroy()
|
||||||
|
reject(new Error('微博请求超时'))
|
||||||
|
})
|
||||||
|
|
||||||
|
req.on('error', reject)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCookieArray(entries: BrowserCookieEntry[]): string {
|
||||||
|
const picked = new Map<string, string>()
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const name = String(entry?.name || '').trim()
|
||||||
|
const value = String(entry?.value || '').trim()
|
||||||
|
const domain = String(entry?.domain || '').trim().toLowerCase()
|
||||||
|
|
||||||
|
if (!name || !value) continue
|
||||||
|
if (domain && !domain.includes('weibo.com') && !domain.includes('weibo.cn')) continue
|
||||||
|
|
||||||
|
picked.set(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(picked.entries())
|
||||||
|
.map(([name, value]) => `${name}=${value}`)
|
||||||
|
.join('; ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWeiboCookieInput(rawInput: string): string {
|
||||||
|
const trimmed = String(rawInput || '').trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as unknown
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const normalized = normalizeCookieArray(parsed as BrowserCookieEntry[])
|
||||||
|
if (normalized) return normalized
|
||||||
|
throw new Error('Cookie JSON 中未找到可用的微博 Cookie 项')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof SyntaxError)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.replace(/^Cookie:\s*/i, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWeiboUid(input: string): string {
|
||||||
|
const trimmed = String(input || '').trim()
|
||||||
|
const directMatch = trimmed.match(/^\d{5,}$/)
|
||||||
|
if (directMatch) return directMatch[0]
|
||||||
|
|
||||||
|
const linkMatch = trimmed.match(/(?:weibo\.com|m\.weibo\.cn)\/u\/(\d{5,})/i)
|
||||||
|
if (linkMatch) return linkMatch[1]
|
||||||
|
|
||||||
|
throw new Error('请输入有效的微博 UID(纯数字)')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeWeiboText(text: string): string {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/\u200b|\u200c|\u200d|\ufeff/g, '')
|
||||||
|
.replace(/https?:\/\/t\.cn\/[A-Za-z0-9]+/g, ' ')
|
||||||
|
.replace(/ +/g, ' ')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRetweetText(item: Pick<WeiboWaterFallItem, 'text_raw' | 'retweeted_status'>): string {
|
||||||
|
const baseText = sanitizeWeiboText(item.text_raw || '')
|
||||||
|
const retweetText = sanitizeWeiboText(item.retweeted_status?.text_raw || '')
|
||||||
|
if (!retweetText) return baseText
|
||||||
|
if (!baseText || baseText === '转发微博') return `转发:${retweetText}`
|
||||||
|
return `${baseText}\n\n转发内容:${retweetText}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCacheKey(uid: string, count: number, cookie: string): string {
|
||||||
|
const cookieHash = createHash('sha1').update(cookie).digest('hex')
|
||||||
|
return `${uid}:${count}:${cookieHash}`
|
||||||
|
}
|
||||||
|
|
||||||
|
class WeiboService {
|
||||||
|
private recentPostsCache = new Map<string, CachedRecentPosts>()
|
||||||
|
|
||||||
|
clearCache(): void {
|
||||||
|
this.recentPostsCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateUid(
|
||||||
|
uidInput: string,
|
||||||
|
cookieInput: string
|
||||||
|
): Promise<{ success: boolean; uid?: string; screenName?: string; error?: string }> {
|
||||||
|
try {
|
||||||
|
const uid = normalizeWeiboUid(uidInput)
|
||||||
|
const cookie = normalizeWeiboCookieInput(cookieInput)
|
||||||
|
if (!cookie) {
|
||||||
|
return { success: true, uid }
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline = await this.fetchTimeline(uid, cookie)
|
||||||
|
const firstItem = timeline.data?.list?.[0]
|
||||||
|
if (!firstItem) {
|
||||||
|
return { success: false, error: '该微博账号暂无可读取的近期公开内容,或当前 Cookie 已失效' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
uid,
|
||||||
|
screenName: firstItem.user?.screen_name
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message || '微博 UID 校验失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchRecentPosts(
|
||||||
|
uidInput: string,
|
||||||
|
cookieInput: string,
|
||||||
|
requestedCount: number
|
||||||
|
): Promise<WeiboRecentPost[]> {
|
||||||
|
const uid = normalizeWeiboUid(uidInput)
|
||||||
|
const cookie = normalizeWeiboCookieInput(cookieInput)
|
||||||
|
const hasCookie = Boolean(cookie)
|
||||||
|
|
||||||
|
const count = Math.max(1, Math.min(WEIBO_MAX_POSTS, Math.floor(Number(requestedCount) || 0)))
|
||||||
|
const cacheKey = buildCacheKey(uid, count, hasCookie ? cookie : '__no_cookie_mobile__')
|
||||||
|
const cached = this.recentPostsCache.get(cacheKey)
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
if (cached && cached.expiresAt > now) {
|
||||||
|
return cached.posts
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawItems = hasCookie
|
||||||
|
? (await this.fetchTimeline(uid, cookie)).data?.list || []
|
||||||
|
: await this.fetchMobileTimeline(uid)
|
||||||
|
const posts: WeiboRecentPost[] = []
|
||||||
|
|
||||||
|
for (const item of rawItems) {
|
||||||
|
if (posts.length >= count) break
|
||||||
|
|
||||||
|
const id = String(item.idstr || item.id || '').trim()
|
||||||
|
if (!id) continue
|
||||||
|
|
||||||
|
let text = mergeRetweetText(item)
|
||||||
|
if (item.isLongText && hasCookie) {
|
||||||
|
try {
|
||||||
|
const detail = await this.fetchDetail(id, cookie)
|
||||||
|
text = mergeRetweetText(detail)
|
||||||
|
} catch {
|
||||||
|
// 长文补抓失败时回退到列表摘要
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text = sanitizeWeiboText(text)
|
||||||
|
if (!text) continue
|
||||||
|
|
||||||
|
posts.push({
|
||||||
|
id,
|
||||||
|
createdAt: String(item.created_at || ''),
|
||||||
|
url: `https://m.weibo.cn/detail/${id}`,
|
||||||
|
text,
|
||||||
|
screenName: item.user?.screen_name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recentPostsCache.set(cacheKey, {
|
||||||
|
expiresAt: now + WEIBO_CACHE_TTL_MS,
|
||||||
|
posts
|
||||||
|
})
|
||||||
|
|
||||||
|
return posts
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchTimeline(uid: string, cookie: string): Promise<WeiboWaterFallResponse> {
|
||||||
|
return requestJson<WeiboWaterFallResponse>(
|
||||||
|
`https://weibo.com/ajax/profile/getWaterFallContent?uid=${encodeURIComponent(uid)}`,
|
||||||
|
{
|
||||||
|
cookie,
|
||||||
|
referer: `https://weibo.com/u/${encodeURIComponent(uid)}`
|
||||||
|
}
|
||||||
|
).then((response) => {
|
||||||
|
if (response.ok !== 1 || !Array.isArray(response.data?.list)) {
|
||||||
|
throw new Error('微博时间线获取失败,请检查 Cookie 是否仍然有效')
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchMobileTimeline(uid: string): Promise<WeiboWaterFallItem[]> {
|
||||||
|
const containerid = `107603${uid}`
|
||||||
|
return requestJson<MWeiboContainerResponse>(
|
||||||
|
`https://m.weibo.cn/api/container/getIndex?type=uid&value=${encodeURIComponent(uid)}&containerid=${encodeURIComponent(containerid)}`,
|
||||||
|
{
|
||||||
|
referer: `https://m.weibo.cn/u/${encodeURIComponent(uid)}`,
|
||||||
|
userAgent: WEIBO_MOBILE_USER_AGENT
|
||||||
|
}
|
||||||
|
).then((response) => {
|
||||||
|
if (response.ok !== 1 || !Array.isArray(response.data?.cards)) {
|
||||||
|
throw new Error('微博时间线获取失败,请稍后重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: WeiboWaterFallItem[] = []
|
||||||
|
for (const card of response.data.cards) {
|
||||||
|
if (card?.mblog) rows.push(card.mblog)
|
||||||
|
if (Array.isArray(card?.card_group)) {
|
||||||
|
for (const subCard of card.card_group) {
|
||||||
|
if (subCard?.mblog) rows.push(subCard.mblog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new Error('该微博账号暂无可读取的近期公开内容')
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchDetail(id: string, cookie: string): Promise<WeiboStatusShowResponse> {
|
||||||
|
return requestJson<WeiboStatusShowResponse>(
|
||||||
|
`https://weibo.com/ajax/statuses/show?id=${encodeURIComponent(id)}&isGetLongText=true`,
|
||||||
|
{
|
||||||
|
cookie,
|
||||||
|
referer: `https://weibo.com/detail/${encodeURIComponent(id)}`
|
||||||
|
}
|
||||||
|
).then((response) => {
|
||||||
|
if (!response || (!response.id && !response.idstr)) {
|
||||||
|
throw new Error('微博详情获取失败')
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const weiboService = new WeiboService()
|
||||||
@@ -1,279 +1,592 @@
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
||||||
|
import { pathToFileURL } from 'url'
|
||||||
|
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 {
|
||||||
videoUrl?: string // 视频文件路径(用于 readFile)
|
videoUrl?: string // 视频文件路径(用于 readFile)
|
||||||
coverUrl?: string // 封面 data URL
|
coverUrl?: string // 封面 data URL
|
||||||
thumbUrl?: string // 缩略图 data URL
|
thumbUrl?: string // 缩略图 data URL
|
||||||
exists: boolean
|
exists: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TimedCacheEntry<T> {
|
||||||
|
value: T
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoIndexEntry {
|
||||||
|
videoPath?: string
|
||||||
|
coverPath?: string
|
||||||
|
thumbPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PosterFormat = 'dataUrl' | 'fileUrl'
|
||||||
|
|
||||||
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 readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
||||||
|
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
|
||||||
|
private readonly videoIndexCacheTtlMs = 90 * 1000
|
||||||
|
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
|
||||||
private getDbPath(): string {
|
cache.delete(oldestKey)
|
||||||
return this.configService.get('dbPath') || ''
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据库根目录
|
||||||
|
*/
|
||||||
|
private getDbPath(): string {
|
||||||
|
return this.configService.get('dbPath') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户的wxid
|
||||||
|
*/
|
||||||
|
private getMyWxid(): string {
|
||||||
|
return this.configService.get('myWxid') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 wxid 目录名(去掉后缀)
|
||||||
|
*/
|
||||||
|
private cleanWxid(wxid: string): string {
|
||||||
|
const trimmed = wxid.trim()
|
||||||
|
if (!trimmed) return trimmed
|
||||||
|
|
||||||
|
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||||
|
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||||
|
if (match) return match[1]
|
||||||
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
* 获取当前用户的wxid
|
if (suffixMatch) return suffixMatch[1]
|
||||||
*/
|
|
||||||
private getMyWxid(): string {
|
return trimmed
|
||||||
return this.configService.get('myWxid') || ''
|
}
|
||||||
|
|
||||||
|
private getScopeKey(dbPath: string, wxid: string): string {
|
||||||
|
return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveVideoBaseDir(dbPath: string, wxid: string): string {
|
||||||
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
|
const wxidLower = wxid.toLowerCase()
|
||||||
|
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||||
|
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||||
|
if (dbPathContainsWxid) {
|
||||||
|
return join(dbPath, 'msg', 'video')
|
||||||
|
}
|
||||||
|
return join(dbPath, wxid, 'msg', 'video')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] {
|
||||||
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
|
const wxidLower = wxid.toLowerCase()
|
||||||
|
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||||
|
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||||
|
|
||||||
|
if (dbPathContainsWxid) {
|
||||||
|
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return [
|
||||||
* 获取缓存目录(解密后的数据库存放位置)
|
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
|
||||||
*/
|
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
|
||||||
private getCachePath(): string {
|
]
|
||||||
return this.configService.getCacheBasePath()
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 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
|
||||||
* 清理 wxid 目录名(去掉后缀)
|
|
||||||
*/
|
|
||||||
private cleanWxid(wxid: string): string {
|
|
||||||
const trimmed = wxid.trim()
|
|
||||||
if (!trimmed) return trimmed
|
|
||||||
|
|
||||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid)
|
||||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
for (const p of encryptedDbPaths) {
|
||||||
if (match) return match[1]
|
if (!existsSync(p) || unresolvedSet.size === 0) continue
|
||||||
return trimmed
|
const unresolved = Array.from(unresolvedSet)
|
||||||
}
|
const requests = unresolved.map((md5) => ({ md5, dbPath: p }))
|
||||||
|
try {
|
||||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests)
|
||||||
if (suffixMatch) return suffixMatch[1]
|
if (batchResult.success && Array.isArray(batchResult.rows)) {
|
||||||
|
for (const row of batchResult.rows) {
|
||||||
return trimmed
|
const index = Number.isFinite(Number(row?.index)) ? Math.floor(Number(row?.index)) : -1
|
||||||
}
|
const inputMd5 = index >= 0 && index < requests.length
|
||||||
|
? requests[index].md5
|
||||||
/**
|
: String(row?.md5 || '').trim().toLowerCase()
|
||||||
* 从 video_hardlink_info_v4 表查询视频文件名
|
if (!inputMd5) continue
|
||||||
* 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3)
|
const resolvedMd5 = row?.success && row?.data?.resolved_md5
|
||||||
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
|
? String(row.data.resolved_md5).trim().toLowerCase()
|
||||||
*/
|
: ''
|
||||||
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
if (!resolvedMd5) continue
|
||||||
const cachePath = this.getCachePath()
|
const cacheKey = `${scopeKey}|${inputMd5}`
|
||||||
const dbPath = this.getDbPath()
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
const wxid = this.getMyWxid()
|
resolvedMap.set(inputMd5, resolvedMd5)
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
unresolvedSet.delete(inputMd5)
|
||||||
|
}
|
||||||
if (!wxid) return undefined
|
|
||||||
|
|
||||||
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
|
||||||
if (cachePath) {
|
|
||||||
const cacheDbPaths = [
|
|
||||||
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) {
|
|
||||||
// 忽略错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
|
||||||
if (dbPath) {
|
|
||||||
// 检查 dbPath 是否已经包含 wxid
|
|
||||||
const dbPathLower = dbPath.toLowerCase()
|
|
||||||
const wxidLower = wxid.toLowerCase()
|
|
||||||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
|
||||||
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
|
||||||
|
|
||||||
const encryptedDbPaths: string[] = []
|
|
||||||
if (dbPathContainsWxid) {
|
|
||||||
// dbPath 已包含 wxid,不需要再拼接
|
|
||||||
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'))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const p of encryptedDbPaths) {
|
|
||||||
if (existsSync(p)) {
|
|
||||||
try {
|
|
||||||
const escapedMd5 = md5.replace(/'/g, "''")
|
|
||||||
|
|
||||||
// 用 md5 字段查询,获取 file_name
|
|
||||||
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
|
||||||
|
|
||||||
const result = await wcdbService.execQuery('media', p, sql)
|
|
||||||
|
|
||||||
if (result.success && result.rows && result.rows.length > 0) {
|
|
||||||
const row = result.rows[0]
|
|
||||||
if (row?.file_name) {
|
|
||||||
// 提取不带扩展名的文件名作为实际视频 MD5
|
|
||||||
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
|
||||||
return realMd5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将文件转换为 data URL
|
|
||||||
*/
|
|
||||||
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
|
|
||||||
try {
|
|
||||||
if (!existsSync(filePath)) return undefined
|
|
||||||
const buffer = readFileSync(filePath)
|
|
||||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据视频MD5获取视频文件信息
|
|
||||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
|
||||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
|
||||||
*/
|
|
||||||
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
|
||||||
const dbPath = this.getDbPath()
|
|
||||||
const wxid = this.getMyWxid()
|
|
||||||
|
|
||||||
if (!dbPath || !wxid || !videoMd5) {
|
|
||||||
return { exists: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先尝试从数据库查询真正的视频文件名
|
|
||||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
|
||||||
|
|
||||||
// 检查 dbPath 是否已经包含 wxid,避免重复拼接
|
|
||||||
const dbPathLower = dbPath.toLowerCase()
|
|
||||||
const wxidLower = wxid.toLowerCase()
|
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
|
||||||
|
|
||||||
let videoBaseDir: string
|
|
||||||
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
|
|
||||||
// dbPath 已经包含 wxid,直接使用
|
|
||||||
videoBaseDir = join(dbPath, 'msg', 'video')
|
|
||||||
} else {
|
} else {
|
||||||
// dbPath 不包含 wxid,需要拼接
|
// 兼容不支持批量接口的版本,回退单条请求。
|
||||||
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
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) {
|
||||||
if (!existsSync(videoBaseDir)) {
|
this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) })
|
||||||
return { exists: false }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 遍历年月目录查找视频文件
|
|
||||||
try {
|
|
||||||
const allDirs = readdirSync(videoBaseDir)
|
|
||||||
|
|
||||||
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
|
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
for (const md5 of unresolvedSet) {
|
||||||
* 根据消息内容解析视频MD5
|
const cacheKey = `${scopeKey}|${md5}`
|
||||||
*/
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
parseVideoMd5(content: string): string | undefined {
|
}
|
||||||
|
|
||||||
// 打印前500字符看看 XML 结构
|
return resolvedMap
|
||||||
|
}
|
||||||
|
|
||||||
if (!content) return undefined
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
|
||||||
|
// 视频链路已改为直接使用 packed_info_data 提取出的文件名索引本地目录。
|
||||||
|
// 该预热接口保留仅为兼容旧调用方,不再查询 hardlink.db。
|
||||||
|
void md5List
|
||||||
|
}
|
||||||
|
|
||||||
|
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
|
||||||
|
try {
|
||||||
|
if (!filePath || !existsSync(filePath)) return undefined
|
||||||
|
if (posterFormat === 'fileUrl') return pathToFileURL(filePath).toString()
|
||||||
|
const buffer = readFileSync(filePath)
|
||||||
|
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
// 提取所有可能的 md5 值进行日志
|
files = readdirSync(dirPath)
|
||||||
const allMd5s: string[] = []
|
} catch {
|
||||||
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
|
continue
|
||||||
let match
|
|
||||||
while ((match = md5Regex.exec(content)) !== null) {
|
|
||||||
allMd5s.push(`${match[0]}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取 md5(用于查询 hardlink.db)
|
|
||||||
// 注意:不是 rawmd5,rawmd5 是另一个值
|
|
||||||
// 格式: md5="xxx" 或 <md5>xxx</md5>
|
|
||||||
|
|
||||||
// 尝试从videomsg标签中提取md5
|
|
||||||
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)
|
|
||||||
if (attrMatch) {
|
|
||||||
return attrMatch[1].toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
|
||||||
if (md5Match) {
|
|
||||||
return md5Match[1].toLowerCase()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[VideoService] 解析视频MD5失败:', e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
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 normalizeVideoLookupKey(value: string): string {
|
||||||
|
let text = String(value || '').trim().toLowerCase()
|
||||||
|
if (!text) return ''
|
||||||
|
text = text.replace(/^.*[\\/]/, '')
|
||||||
|
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
|
||||||
|
text = text.replace(/_thumb$/, '')
|
||||||
|
const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
|
||||||
|
if (direct) {
|
||||||
|
const suffix = /_raw$/i.test(text) ? '_raw' : ''
|
||||||
|
return `${direct[1].toLowerCase()}${suffix}`
|
||||||
|
}
|
||||||
|
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
|
||||||
|
if (preferred32?.[1]) return preferred32[1].toLowerCase()
|
||||||
|
const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
|
||||||
|
return String(fallback?.[1] || '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise<VideoInfo> {
|
||||||
|
void posterFormat
|
||||||
|
if (!includePoster) return info
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据视频MD5获取视频文件信息
|
||||||
|
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||||
|
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||||
|
*/
|
||||||
|
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 wxid = this.getMyWxid()
|
||||||
|
|
||||||
|
this.log('getVideoInfo 开始', { videoMd5: normalizedMd5, dbPath, wxid })
|
||||||
|
|
||||||
|
if (!dbPath || !wxid || !normalizedMd5) {
|
||||||
|
this.log('getVideoInfo: 参数缺失', { hasDbPath: !!dbPath, hasWxid: !!wxid, hasVideoMd5: !!normalizedMd5 })
|
||||||
|
return { exists: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopeKey = this.getScopeKey(dbPath, wxid)
|
||||||
|
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}|format=${posterFormat}`
|
||||||
|
|
||||||
|
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
|
||||||
|
if (cachedInfo) return cachedInfo
|
||||||
|
|
||||||
|
const pending = this.pendingVideoInfo.get(cacheKey)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const task = (async (): Promise<VideoInfo> => {
|
||||||
|
const realVideoMd5 = this.normalizeVideoLookupKey(normalizedMd5) || normalizedMd5
|
||||||
|
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
|
||||||
|
|
||||||
|
if (!existsSync(videoBaseDir)) {
|
||||||
|
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: 未找到视频', { lookupKey: normalizedMd5, normalizedKey: realVideoMd5 })
|
||||||
|
return miss
|
||||||
|
})()
|
||||||
|
|
||||||
|
this.pendingVideoInfo.set(cacheKey, task)
|
||||||
|
try {
|
||||||
|
return await task
|
||||||
|
} finally {
|
||||||
|
this.pendingVideoInfo.delete(cacheKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据消息内容解析视频MD5
|
||||||
|
*/
|
||||||
|
parseVideoMd5(content: string): string | undefined {
|
||||||
|
if (!content) return undefined
|
||||||
|
|
||||||
|
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
|
||||||
|
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 收集所有 md5 相关属性,方便对比
|
||||||
|
const allMd5Attrs: string[] = []
|
||||||
|
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
|
||||||
|
let match
|
||||||
|
while ((match = md5Regex.exec(content)) !== null) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法2:从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5)
|
||||||
|
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (rawMd5Match) {
|
||||||
|
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
|
||||||
|
return rawMd5Match[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
|
||||||
|
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (attrMatch) {
|
||||||
|
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
|
||||||
|
return attrMatch[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法4:<md5>...</md5> 标签
|
||||||
|
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||||
|
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) {
|
||||||
|
this.log('parseVideoMd5 异常', { error: String(e) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const videoService = new VideoService()
|
export const videoService = new VideoService()
|
||||||
|
|||||||
@@ -48,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
|
||||||
@@ -206,17 +246,20 @@ export class VoiceTranscribeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { Worker } = require('worker_threads')
|
const { fork } = require('child_process')
|
||||||
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(),
|
||||||
modelPath,
|
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||||
tokensPath,
|
serialization: 'advanced'
|
||||||
wavData,
|
})
|
||||||
sampleRate: 16000,
|
worker.send({
|
||||||
languages: supportedLanguages
|
modelPath,
|
||||||
}
|
tokensPath,
|
||||||
|
wavData,
|
||||||
|
sampleRate: 16000,
|
||||||
|
languages: supportedLanguages
|
||||||
})
|
})
|
||||||
|
|
||||||
let finalTranscript = ''
|
let finalTranscript = ''
|
||||||
@@ -227,17 +270,31 @@ export class VoiceTranscribeService {
|
|||||||
} 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) => resolve({ success: false, error: String(err) }))
|
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
|
||||||
worker.on('exit', (code: number) => {
|
worker.on('exit', (code: number | null, signal: string | null) => {
|
||||||
if (code !== 0) resolve({ success: false, error: `Worker exited with code ${code}` })
|
if (code === null || signal === 'SIGSEGV') {
|
||||||
|
|
||||||
|
console.error(`[VoiceTranscribe] Worker 异常崩溃,信号: ${signal}。可能是由于底层 C++ 运行库在当前系统上发生段错误。`);
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: 'SEGFAULT_ERROR'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
resolve({ success: false, error: `Worker exited with code ${code}` });
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,9 +25,7 @@ export class WcdbService {
|
|||||||
private logEnabled = false
|
private logEnabled = false
|
||||||
private monitorListener: ((type: string, json: string) => void) | null = null
|
private monitorListener: ((type: string, json: string) => void) | null = null
|
||||||
|
|
||||||
constructor() {
|
constructor() {}
|
||||||
this.initWorker()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化 Worker 线程
|
* 初始化 Worker 线程
|
||||||
@@ -80,7 +78,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,7 +134,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;
|
||||||
this.callWorker('setMonitor').catch(() => { });
|
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,6 +162,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')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关闭数据库连接
|
* 关闭数据库连接
|
||||||
*/
|
*/
|
||||||
@@ -174,10 +176,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,6 +220,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 })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取联系人昵称
|
* 获取联系人昵称
|
||||||
*/
|
*/
|
||||||
@@ -283,6 +362,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 })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取联系人详情
|
* 获取联系人详情
|
||||||
*/
|
*/
|
||||||
@@ -290,6 +377,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 })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取聚合统计数据
|
* 获取聚合统计数据
|
||||||
*/
|
*/
|
||||||
@@ -332,6 +446,19 @@ export class WcdbService {
|
|||||||
return this.callWorker('getGroupStats', { chatroomId, beginTimestamp, endTimestamp })
|
return this.callWorker('getGroupStats', { chatroomId, beginTimestamp, endTimestamp })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMyFootprintStats(options: {
|
||||||
|
beginTimestamp?: number
|
||||||
|
endTimestamp?: number
|
||||||
|
myWxid?: string
|
||||||
|
privateSessionIds?: string[]
|
||||||
|
groupSessionIds?: string[]
|
||||||
|
mentionLimit?: number
|
||||||
|
privateLimit?: number
|
||||||
|
mentionMode?: 'text_at_me' | string
|
||||||
|
}): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('getMyFootprintStats', { options })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开消息游标
|
* 打开消息游标
|
||||||
*/
|
*/
|
||||||
@@ -361,7 +488,7 @@ export class WcdbService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行 SQL 查询(支持参数化查询)
|
* 执行 SQL 查询(仅主进程内部使用:fallback/diagnostic/低频兼容)
|
||||||
*/
|
*/
|
||||||
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): 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, params })
|
return this.callWorker('execQuery', { kind, path, sql, params })
|
||||||
@@ -374,6 +501,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 })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列出消息数据库
|
* 列出消息数据库
|
||||||
*/
|
*/
|
||||||
@@ -395,6 +536,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 })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取语音数据
|
* 获取语音数据
|
||||||
*/
|
*/
|
||||||
@@ -402,6 +547,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 })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取朋友圈
|
* 获取朋友圈
|
||||||
*/
|
*/
|
||||||
@@ -416,8 +595,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')
|
||||||
@@ -444,6 +677,27 @@ export class WcdbService {
|
|||||||
return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint })
|
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', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
20
electron/utils/pathUtils.ts
Normal file
20
electron/utils/pathUtils.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { homedir } from 'os'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand "~" prefix to current user's home directory.
|
||||||
|
* Examples:
|
||||||
|
* - "~" => "/Users/alex"
|
||||||
|
* - "~/Library/..." => "/Users/alex/Library/..."
|
||||||
|
*/
|
||||||
|
export function expandHomePath(inputPath: string): string {
|
||||||
|
const raw = String(inputPath || '').trim()
|
||||||
|
if (!raw) return raw
|
||||||
|
|
||||||
|
if (raw === '~') return homedir()
|
||||||
|
if (/^~[\\/]/.test(raw)) {
|
||||||
|
return `${homedir()}${raw.slice(1)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -84,9 +113,33 @@ if (parentPort) {
|
|||||||
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
|
||||||
@@ -105,6 +158,9 @@ if (parentPort) {
|
|||||||
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
|
||||||
|
case 'getMyFootprintStats':
|
||||||
|
result = await core.getMyFootprintStats(payload.options || {})
|
||||||
|
break
|
||||||
case 'openMessageCursor':
|
case 'openMessageCursor':
|
||||||
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
|
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
@@ -123,6 +179,12 @@ if (parentPort) {
|
|||||||
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
|
||||||
@@ -132,18 +194,69 @@ 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
|
||||||
@@ -156,7 +269,15 @@ if (parentPort) {
|
|||||||
case 'deleteMessage':
|
case 'deleteMessage':
|
||||||
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
|
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
|
||||||
break
|
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,198 +1,343 @@
|
|||||||
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,
|
||||||
show: false,
|
show: false,
|
||||||
alwaysOnTop: true,
|
alwaysOnTop: true,
|
||||||
skipTaskbar: true,
|
skipTaskbar: true,
|
||||||
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 }); // 初始点击穿透
|
||||||
|
|
||||||
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
||||||
// 实际上,我们希望窗口可点击。
|
// 实际上,我们希望窗口可点击。
|
||||||
// 我们将在显示时将忽略鼠标事件设为 false。
|
// 我们将在显示时将忽略鼠标事件设为 false。
|
||||||
|
|
||||||
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 = typeof data.sessionId === "string" ? data.sessionId : "";
|
||||||
|
// 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响
|
||||||
|
const isSystemNotification = sessionId.startsWith("weflow-");
|
||||||
|
|
||||||
if (sessionId && filterMode !== 'all' && filterList.length > 0) {
|
if (!isSystemNotification && filterMode !== "all") {
|
||||||
const isInList = filterList.includes(sessionId)
|
const isInList = sessionId !== "" && filterList.includes(sessionId);
|
||||||
if (filterMode === 'whitelist' && !isInList) {
|
if (filterMode === "whitelist" && !isInList) {
|
||||||
// 白名单模式:不在列表中则不显示
|
// 白名单模式:不在列表中则不显示(空列表视为全部拦截)
|
||||||
return
|
return;
|
||||||
}
|
|
||||||
if (filterMode === 'blacklist' && isInList) {
|
|
||||||
// 黑名单模式:在列表中则不显示
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (filterMode === "blacklist" && isInList) {
|
||||||
let win = notificationWindow
|
// 黑名单模式:在列表中则不显示
|
||||||
if (!win || win.isDestroyed()) {
|
return;
|
||||||
win = createNotificationWindow()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!win) return
|
// Linux 使用 D-Bus 通知
|
||||||
|
if (isLinux) {
|
||||||
|
await showLinuxNotification(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 确保加载完成
|
let win = notificationWindow;
|
||||||
if (win.webContents.isLoading()) {
|
if (!win || win.isDestroyed()) {
|
||||||
win.once('ready-to-show', () => {
|
win = createNotificationWindow();
|
||||||
showAndSend(win!, data)
|
}
|
||||||
})
|
|
||||||
} else {
|
if (!win) return;
|
||||||
showAndSend(win, data)
|
|
||||||
}
|
// 确保加载完成
|
||||||
|
if (win.webContents.isLoading()) {
|
||||||
|
win.once("ready-to-show", () => {
|
||||||
|
showAndSend(win!, data);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showAndSend(win, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastNotificationData: any = null
|
// 显示Linux通知
|
||||||
|
async function showLinuxNotification(data: any) {
|
||||||
|
if (!linuxNotificationService) {
|
||||||
|
try {
|
||||||
|
linuxNotificationService =
|
||||||
|
await import("../services/linuxNotificationService");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[NotificationWindow] Failed to load Linux notification service:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { showLinuxNotification: showNotification } = linuxNotificationService;
|
||||||
|
|
||||||
|
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) {
|
async function showAndSend(win: BrowserWindow, data: any) {
|
||||||
lastNotificationData = data
|
lastNotificationData = data;
|
||||||
const config = ConfigService.getInstance()
|
const config = ConfigService.getInstance();
|
||||||
const position = (await config.get('notificationPosition')) || 'top-right'
|
const position = (await config.get("notificationPosition")) || "top-right";
|
||||||
|
|
||||||
// 更新位置
|
// 更新位置
|
||||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
const { width: screenWidth, height: screenHeight } =
|
||||||
const winWidth = 344
|
screen.getPrimaryDisplay().workAreaSize;
|
||||||
const winHeight = 114
|
const winWidth = position === "top-center" ? 280 : 344;
|
||||||
const padding = 20
|
const winHeight = 114;
|
||||||
|
const padding = 20;
|
||||||
|
|
||||||
let x = 0
|
let x = 0;
|
||||||
let y = 0
|
let y = 0;
|
||||||
|
|
||||||
switch (position) {
|
switch (position) {
|
||||||
case 'top-right':
|
case "top-center":
|
||||||
x = screenWidth - winWidth - padding
|
x = (screenWidth - winWidth) / 2;
|
||||||
y = padding
|
y = padding;
|
||||||
break
|
break;
|
||||||
case 'bottom-right':
|
case "top-right":
|
||||||
x = screenWidth - winWidth - padding
|
x = screenWidth - winWidth - padding;
|
||||||
y = screenHeight - winHeight - padding
|
y = padding;
|
||||||
break
|
break;
|
||||||
case 'top-left':
|
case "bottom-right":
|
||||||
x = padding
|
x = screenWidth - winWidth - padding;
|
||||||
y = padding
|
y = screenHeight - winHeight - padding;
|
||||||
break
|
break;
|
||||||
case 'bottom-left':
|
case "top-left":
|
||||||
x = padding
|
x = padding;
|
||||||
y = screenHeight - winHeight - padding
|
y = padding;
|
||||||
break
|
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.showInactive(); // 显示但不聚焦
|
||||||
|
win.setAlwaysOnTop(true, "screen-saver"); // 最高层级
|
||||||
|
|
||||||
|
win.webContents.send("notification:show", { ...data, position });
|
||||||
|
|
||||||
|
// 自动关闭计时器通常由渲染进程管理
|
||||||
|
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册通知处理
|
||||||
|
export async function registerNotificationHandlers() {
|
||||||
|
// Linux: 初始化D-Bus服务
|
||||||
|
if (isLinux) {
|
||||||
|
try {
|
||||||
|
const linuxNotificationModule =
|
||||||
|
await import("../services/linuxNotificationService");
|
||||||
|
linuxNotificationService = linuxNotificationModule;
|
||||||
|
|
||||||
|
// 初始化服务
|
||||||
|
await linuxNotificationModule.initLinuxNotificationService();
|
||||||
|
|
||||||
|
// 在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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
win.setPosition(Math.floor(x), Math.floor(y))
|
ipcMain.handle("notification:show", (_, data) => {
|
||||||
win.setSize(winWidth, winHeight) // 确保尺寸
|
showNotification(data);
|
||||||
|
});
|
||||||
|
|
||||||
// 设为可交互
|
ipcMain.handle("notification:close", () => {
|
||||||
win.setIgnoreMouseEvents(false)
|
if (isLinux && linuxNotificationService) {
|
||||||
win.showInactive() // 显示但不聚焦
|
// 注册通知点击回调函数。Linux通知通过D-Bus自动关闭,但我们可以根据需要进行跟踪
|
||||||
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
return;
|
||||||
|
}
|
||||||
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
|
notificationWindow.hide();
|
||||||
|
notificationWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
win.webContents.send('notification:show', data)
|
// Handle renderer ready event (fix race condition)
|
||||||
|
ipcMain.on("notification:ready", (event) => {
|
||||||
|
if (isLinux) {
|
||||||
|
// Linux不需要通知窗口,拦截通知窗口渲染
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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
|
||||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
ipcMain.on("notification:resize", (event, { width, height }) => {
|
||||||
}
|
if (isLinux) {
|
||||||
|
// Linux 通知通过D-Bus自动调整大小
|
||||||
export function registerNotificationHandlers() {
|
return;
|
||||||
ipcMain.handle('notification:show', (_, data) => {
|
}
|
||||||
showNotification(data)
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
})
|
// Enforce max-height if needed, or trust renderer
|
||||||
|
// Ensure it doesn't go off screen bottom?
|
||||||
ipcMain.handle('notification:close', () => {
|
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
|
||||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
// If we resize, we should re-calculate position to keep it anchored?
|
||||||
notificationWindow.hide()
|
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
|
||||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true })
|
// If bottom-right, growing down pushes it off screen.
|
||||||
}
|
|
||||||
})
|
// Simple version: just setSize. For V1 we assume Top-Right.
|
||||||
|
// But wait, the config supports bottom-right.
|
||||||
// Handle renderer ready event (fix race condition)
|
// We can re-call setPosition or just let it be.
|
||||||
ipcMain.on('notification:ready', (event) => {
|
// If bottom-right, y needs to prevent overflow.
|
||||||
console.log('[NotificationWindow] Renderer ready, checking cached data')
|
|
||||||
if (lastNotificationData && notificationWindow && !notificationWindow.isDestroyed()) {
|
// Ideally we get current config position
|
||||||
console.log('[NotificationWindow] Re-sending cached data')
|
const bounds = notificationWindow.getBounds();
|
||||||
notificationWindow.webContents.send('notification:show', lastNotificationData)
|
// Check if we need to adjust Y?
|
||||||
}
|
// For now, let's just set the size as requested.
|
||||||
})
|
notificationWindow.setSize(Math.round(width), Math.round(height));
|
||||||
|
}
|
||||||
// Handle resize request from renderer
|
});
|
||||||
ipcMain.on('notification:resize', (event, { width, height }) => {
|
|
||||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
// 'notification-clicked' 在 main.ts 中处理 (导航)
|
||||||
// Enforce max-height if needed, or trust renderer
|
|
||||||
// Ensure it doesn't go off screen bottom?
|
|
||||||
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
|
|
||||||
// If we resize, we should re-calculate position to keep it anchored?
|
|
||||||
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
|
|
||||||
// If bottom-right, growing down pushes it off screen.
|
|
||||||
|
|
||||||
// Simple version: just setSize. For V1 we assume Top-Right.
|
|
||||||
// But wait, the config supports bottom-right.
|
|
||||||
// We can re-call setPosition or just let it be.
|
|
||||||
// If bottom-right, y needs to prevent overflow.
|
|
||||||
|
|
||||||
// Ideally we get current config position
|
|
||||||
const bounds = notificationWindow.getBounds()
|
|
||||||
// Check if we need to adjust Y?
|
|
||||||
// For now, let's just set the size as requested.
|
|
||||||
notificationWindow.setSize(Math.round(width), Math.round(height))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 'notification-clicked' 在 main.ts 中处理 (导航)
|
|
||||||
}
|
}
|
||||||
|
|||||||
5127
package-lock.json
generated
5127
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
128
package.json
128
package.json
@@ -1,28 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "2.1.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": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
|
||||||
"rebuild": "electron-rebuild",
|
"rebuild": "electron-rebuild",
|
||||||
"dev": "vite",
|
"dev": "node scripts/prepare-electron-runtime.cjs && 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": "node scripts/prepare-electron-runtime.cjs && vite --mode electron",
|
||||||
"electron:build": "npm run build"
|
"electron:build": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.5.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts": "^5.5.1",
|
|
||||||
"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",
|
||||||
@@ -31,33 +34,45 @@
|
|||||||
"jieba-wasm": "^2.2.0",
|
"jieba-wasm": "^2.2.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"koffi": "^2.9.0",
|
"koffi": "^2.9.0",
|
||||||
"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-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.14.0",
|
||||||
"react-virtuoso": "^4.18.1",
|
"react-virtuoso": "^4.18.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sherpa-onnx-node": "^1.10.38",
|
"sherpa-onnx-node": "^1.10.38",
|
||||||
"silk-wasm": "^3.7.1",
|
"silk-wasm": "^3.7.1",
|
||||||
|
"sudo-prompt": "^9.2.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.98.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^6.0.5",
|
"vite": "^8.0.9",
|
||||||
"vite-plugin-electron": "^0.28.8",
|
"vite-plugin-electron": "^0.28.8",
|
||||||
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.WeFlow.app",
|
"appId": "com.WeFlow.app",
|
||||||
"publish": {
|
"publish": {
|
||||||
@@ -71,11 +86,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,
|
||||||
@@ -107,6 +168,10 @@
|
|||||||
"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/",
|
"from": "electron/assets/wasm/",
|
||||||
"to": "assets/wasm/"
|
"to": "assets/wasm/"
|
||||||
@@ -119,25 +184,16 @@
|
|||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"node_modules/silk-wasm/**/*",
|
"node_modules/silk-wasm/**/*",
|
||||||
"node_modules/sherpa-onnx-node/**/*",
|
"node_modules/sherpa-onnx-node/**/*",
|
||||||
"node_modules/ffmpeg-static/**/*"
|
"node_modules/sherpa-onnx-*/*",
|
||||||
|
"node_modules/sherpa-onnx-*/**/*",
|
||||||
|
"node_modules/ffmpeg-static/**/*",
|
||||||
|
"resources/wedecrypt/**/*.node"
|
||||||
],
|
],
|
||||||
"extraFiles": [
|
"icon": "resources/icons/macos/icon.icns"
|
||||||
{
|
},
|
||||||
"from": "resources/msvcp140.dll",
|
"overrides": {
|
||||||
"to": "."
|
"picomatch": "^4.0.4",
|
||||||
},
|
"tar": "^7.5.13",
|
||||||
{
|
"immutable": "^5.1.5"
|
||||||
"from": "resources/msvcp140_1.dll",
|
|
||||||
"to": "."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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 |
BIN
resources/fonts/annual-report/CormorantGaramond-Var.ttf
Normal file
BIN
resources/fonts/annual-report/CormorantGaramond-Var.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/annual-report/Inter-Var.ttf
Normal file
BIN
resources/fonts/annual-report/Inter-Var.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/annual-report/NotoSerifSC-Var.ttf
Normal file
BIN
resources/fonts/annual-report/NotoSerifSC-Var.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/annual-report/PlayfairDisplay-Var.ttf
Normal file
BIN
resources/fonts/annual-report/PlayfairDisplay-Var.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/annual-report/SpaceMono-Bold.ttf
Normal file
BIN
resources/fonts/annual-report/SpaceMono-Bold.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/annual-report/SpaceMono-Regular.ttf
Normal file
BIN
resources/fonts/annual-report/SpaceMono-Regular.ttf
Normal file
Binary file not shown.
BIN
resources/icons/macos/icon.icns
Normal file
BIN
resources/icons/macos/icon.icns
Normal file
Binary file not shown.
30
resources/installer/linux/PKGBUILD
Normal file
30
resources/installer/linux/PKGBUILD
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Maintainer: H3CoF6 <h3cof6@gmail.com>
|
||||||
|
pkgname=weflow
|
||||||
|
pkgver=4.3.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="A local WeChat database decryption and analysis tool"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/hicccc77/weflow"
|
||||||
|
license=('CC-BY-NC-SA-4.0')
|
||||||
|
depends=('alsa-lib' 'gtk3' 'nss' 'glibc')
|
||||||
|
options=('!strip' '!debug')
|
||||||
|
|
||||||
|
source=("WeFlow-${pkgver}-Setup.tar.gz::${url}/releases/download/v${pkgver}/WeFlow-${pkgver}-Setup.tar.gz"
|
||||||
|
"weflow.desktop"
|
||||||
|
"icon.png")
|
||||||
|
|
||||||
|
sha256sums=('2859aca2f57c42f4d1516ed229613623c57d3e78b9cb152fcb2b9c1096ab9340'
|
||||||
|
'2cf03766f5c2f1915ad136f060a66f5788ed32b06defe1956e406c73d7e733b7'
|
||||||
|
'b1c412d9c08ae683e231173c16fe73958ad1063f14c9b3852373385e4fcb6f33')
|
||||||
|
|
||||||
|
package() {
|
||||||
|
install -dm755 "${pkgdir}/opt/${pkgname}"
|
||||||
|
|
||||||
|
cp -a "${srcdir}/WeFlow-${pkgver}-Setup/"* "${pkgdir}/opt/${pkgname}/"
|
||||||
|
|
||||||
|
install -dm755 "${pkgdir}/usr/bin"
|
||||||
|
ln -s "/opt/${pkgname}/weflow" "${pkgdir}/usr/bin/${pkgname}"
|
||||||
|
|
||||||
|
install -Dm644 "${srcdir}/weflow.desktop" -t "${pkgdir}/usr/share/applications/"
|
||||||
|
install -Dm644 "${srcdir}/icon.png" "${pkgdir}/usr/share/pixmaps/${pkgname}.png"
|
||||||
|
}
|
||||||
BIN
resources/installer/linux/icon.png
Normal file
BIN
resources/installer/linux/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
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' 启动。"
|
||||||
9
resources/installer/linux/weflow.desktop
Normal file
9
resources/installer/linux/weflow.desktop
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Name=WeFlow
|
||||||
|
Comment=一个本地的微信聊天记录导出和年度报告应用
|
||||||
|
Exec=/usr/bin/weflow %U
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Icon=weflow
|
||||||
|
StartupWMClass=WeFlow
|
||||||
|
Categories=Utility;
|
||||||
BIN
resources/key/linux/x64/xkey_helper_linux
Executable file
BIN
resources/key/linux/x64/xkey_helper_linux
Executable 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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user