mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-11 00:06:04 +00:00
Compare commits
617 Commits
v4.6.0b1
...
refactor/e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f174a19d3 | ||
|
|
b0b9221495 | ||
|
|
f4b3b87d7a | ||
|
|
638a322368 | ||
|
|
bca710dbd4 | ||
|
|
47ade18596 | ||
|
|
733c9cdf16 | ||
|
|
bbc508d42f | ||
|
|
0551d22689 | ||
|
|
53d4edb609 | ||
|
|
f897987ac1 | ||
|
|
8e558ad3a1 | ||
|
|
47fe9bde03 | ||
|
|
5c3a619e2d | ||
|
|
e223edeb45 | ||
|
|
d2c3146334 | ||
|
|
7d9c8e3065 | ||
|
|
f12ed81e1e | ||
|
|
6d4d19b6d7 | ||
|
|
07b90f12a2 | ||
|
|
fd896c6974 | ||
|
|
1fbfa868fb | ||
|
|
ad05819c2e | ||
|
|
0c6f71738c | ||
|
|
af451e7006 | ||
|
|
59f20bcc73 | ||
|
|
7eca3cdfca | ||
|
|
c40354f838 | ||
|
|
21a5b4658a | ||
|
|
073acaa053 | ||
|
|
38759b229d | ||
|
|
efe32e34ae | ||
|
|
46db4de11a | ||
|
|
170a6756f4 | ||
|
|
7330732f62 | ||
|
|
b08e5ca09a | ||
|
|
dff80a0c0a | ||
|
|
f54ae4b91c | ||
|
|
e5b3cced1f | ||
|
|
101e04db6d | ||
|
|
b79edda3a7 | ||
|
|
a20d3d11e5 | ||
|
|
3b4c455813 | ||
|
|
c967a2aa82 | ||
|
|
79cc6da96f | ||
|
|
fee7d48dc3 | ||
|
|
1f67ff2e8d | ||
|
|
8811fb647f | ||
|
|
37b017459d | ||
|
|
4889a3881b | ||
|
|
fe4f95b9a3 | ||
|
|
a2817f6524 | ||
|
|
b9560b26ff | ||
|
|
1ad7071aa0 | ||
|
|
96b041846d | ||
|
|
b68ff1956c | ||
|
|
7e5d74a1ad | ||
|
|
4054ba2a76 | ||
|
|
8a42fd8b21 | ||
|
|
4b9aa20985 | ||
|
|
c7cb42bd79 | ||
|
|
7328881e6f | ||
|
|
894709d577 | ||
|
|
6823069103 | ||
|
|
699545a196 | ||
|
|
f0061817ea | ||
|
|
688202e7d1 | ||
|
|
d46b762d03 | ||
|
|
0963fd5443 | ||
|
|
6471770737 | ||
|
|
314b7d15bb | ||
|
|
c758908745 | ||
|
|
767137aaa0 | ||
|
|
acb2ce6a40 | ||
|
|
67784708d6 | ||
|
|
1bd9c334aa | ||
|
|
17bbc8bf10 | ||
|
|
4a4c0921a4 | ||
|
|
e425cf079a | ||
|
|
245e798b79 | ||
|
|
27fdccce16 | ||
|
|
484643c0ee | ||
|
|
ec61459619 | ||
|
|
66ef744447 | ||
|
|
10d3a9cc92 | ||
|
|
885320e9ae | ||
|
|
ed02ac4710 | ||
|
|
e4841edbaf | ||
|
|
ef7a06b0db | ||
|
|
6fe20c1812 | ||
|
|
9e8c8f79df | ||
|
|
01d06898fb | ||
|
|
0a669c7016 | ||
|
|
b251fc4b89 | ||
|
|
075c85e2bc | ||
|
|
62b63ca2ca | ||
|
|
3680a80248 | ||
|
|
6713b57d01 | ||
|
|
ea13ef87f2 | ||
|
|
197e117900 | ||
|
|
59bd581e88 | ||
|
|
cba83a62e8 | ||
|
|
f412127fb0 | ||
|
|
417b83d3aa | ||
|
|
950da65797 | ||
|
|
3ed35593e9 | ||
|
|
5273bbb23f | ||
|
|
0ceab3f6a5 | ||
|
|
63bdee22b4 | ||
|
|
c55db54fd2 | ||
|
|
aedc097188 | ||
|
|
18b27dd9ef | ||
|
|
3f50a56623 | ||
|
|
57f2e85388 | ||
|
|
503d29ffed | ||
|
|
05f370ca49 | ||
|
|
c7e8eb1214 | ||
|
|
5c182c0f29 | ||
|
|
e4a471af18 | ||
|
|
dfcf9d10e4 | ||
|
|
eb475245ab | ||
|
|
d1b7d56392 | ||
|
|
9f23f4c572 | ||
|
|
1fcdbd472f | ||
|
|
547006cb4a | ||
|
|
92bf9a7ea5 | ||
|
|
832efb4069 | ||
|
|
8f1847d480 | ||
|
|
fe619e415f | ||
|
|
0154ea6cd3 | ||
|
|
8db55267d8 | ||
|
|
b9662250a6 | ||
|
|
d9378c3a88 | ||
|
|
86a4d1bf0b | ||
|
|
ce6e79db8e | ||
|
|
d53e2cb9a0 | ||
|
|
c1168745b7 | ||
|
|
69b87a0d8a | ||
|
|
6637b153f1 | ||
|
|
e768fc6116 | ||
|
|
2442d3bf52 | ||
|
|
42d78817f4 | ||
|
|
4b9f25a05d | ||
|
|
d1f0e07cc0 | ||
|
|
78e55509ae | ||
|
|
2c28635a39 | ||
|
|
5f3cecfbe2 | ||
|
|
12df9d6ee9 | ||
|
|
195f6efeff | ||
|
|
564d829e25 | ||
|
|
58c1916712 | ||
|
|
a8fba46040 | ||
|
|
3115d6f6dd | ||
|
|
323481d69b | ||
|
|
5a5c4295b1 | ||
|
|
88111d87ac | ||
|
|
4e5a6ee79a | ||
|
|
05c684d757 | ||
|
|
2838020580 | ||
|
|
9b34ae2db4 | ||
|
|
f8010a20eb | ||
|
|
917edb3413 | ||
|
|
10425ede34 | ||
|
|
e4b40a8fa0 | ||
|
|
0b8ab4b54b | ||
|
|
49239e0e08 | ||
|
|
aec2a30445 | ||
|
|
c8915ca964 | ||
|
|
a715eddd06 | ||
|
|
2f9c235b41 | ||
|
|
cc4d8838eb | ||
|
|
fa0a77f09f | ||
|
|
fd6a7b73d4 | ||
|
|
bf0848d60b | ||
|
|
e06fac2bb7 | ||
|
|
bec61427a0 | ||
|
|
5fae7b2eb0 | ||
|
|
2eebdfe16a | ||
|
|
9cd3544d59 | ||
|
|
de4d14fee3 | ||
|
|
f29c568381 | ||
|
|
af3f557055 | ||
|
|
b894842736 | ||
|
|
e190029e1f | ||
|
|
e4940a8050 | ||
|
|
617c95ebc4 | ||
|
|
1cdd428bcc | ||
|
|
71ac719aee | ||
|
|
4621e6cc9f | ||
|
|
66087f83e1 | ||
|
|
25f9330491 | ||
|
|
14b1e0d33b | ||
|
|
83ccb33fd3 | ||
|
|
05bcf543ba | ||
|
|
7cd063bb5d | ||
|
|
8f1317b39e | ||
|
|
77a0de5ef0 | ||
|
|
875227a2fe | ||
|
|
2317392ee5 | ||
|
|
c7efa4dd7f | ||
|
|
e701daa8e0 | ||
|
|
1ae99199b2 | ||
|
|
7c067a1cb3 | ||
|
|
478bc62576 | ||
|
|
a740eb8ee9 | ||
|
|
f8aedd02b3 | ||
|
|
ea638cab80 | ||
|
|
7129dd536e | ||
|
|
1b1cc7769b | ||
|
|
44b8354dfd | ||
|
|
55ec9d11ae | ||
|
|
5b3d3801b5 | ||
|
|
9f1ea75d09 | ||
|
|
6e37aae636 | ||
|
|
921d12f596 | ||
|
|
6bf6deaefd | ||
|
|
1201949f2c | ||
|
|
1c419e3591 | ||
|
|
b0a9be77b0 | ||
|
|
e02ade5a30 | ||
|
|
1a51ba8e7e | ||
|
|
e7b22d6ebf | ||
|
|
dddfa8ac79 | ||
|
|
99e2976826 | ||
|
|
71e44f0e54 | ||
|
|
4c904c2375 | ||
|
|
498d030da9 | ||
|
|
c111bf1714 | ||
|
|
6570f276d2 | ||
|
|
42e1e038bd | ||
|
|
d0e54a45c7 | ||
|
|
23fa47b07e | ||
|
|
4902c1d3b2 | ||
|
|
a6f96e5209 | ||
|
|
37c41bcfe4 | ||
|
|
9e223949a7 | ||
|
|
267bd72c63 | ||
|
|
af0d00e5e9 | ||
|
|
244e16c491 | ||
|
|
cad259fe39 | ||
|
|
bc3199bf29 | ||
|
|
127dc455c3 | ||
|
|
e8dc6fde53 | ||
|
|
4a97895dea | ||
|
|
3c0495fc51 | ||
|
|
dfd25deb68 | ||
|
|
f4db53b759 | ||
|
|
9f90341dcb | ||
|
|
67b726afb2 | ||
|
|
01852b81d4 | ||
|
|
4d6f109788 | ||
|
|
e1e5e7aedf | ||
|
|
cd53abc440 | ||
|
|
16a15a122a | ||
|
|
6fa653f232 | ||
|
|
c13971d7d6 | ||
|
|
9c659ce8fa | ||
|
|
c9fc64360f | ||
|
|
88a04fdbe8 | ||
|
|
bbe019f0c6 | ||
|
|
865f6ee81b | ||
|
|
bd5ec59b7c | ||
|
|
9c0cc1003d | ||
|
|
ea07d8ad00 | ||
|
|
3ac3fad4bc | ||
|
|
254a13bba3 | ||
|
|
4355f0fa78 | ||
|
|
031737f05d | ||
|
|
9e366fc536 | ||
|
|
8bd6442965 | ||
|
|
1a1eadb282 | ||
|
|
eed72b1c12 | ||
|
|
351350ea03 | ||
|
|
bc3d6ba92f | ||
|
|
345e4baf2a | ||
|
|
6c64dc057f | ||
|
|
eec0a9c9d9 | ||
|
|
6896a55485 | ||
|
|
4b0fad233e | ||
|
|
52eb991a70 | ||
|
|
10c716be0c | ||
|
|
6e77351eda | ||
|
|
20f5ebd9b8 | ||
|
|
d2c75329cf | ||
|
|
7e2fe082f0 | ||
|
|
d451b059fd | ||
|
|
93c52fcd4c | ||
|
|
f1608682e6 | ||
|
|
077e631c13 | ||
|
|
d7df1f05d1 | ||
|
|
8b8cfb76de | ||
|
|
79311ccde3 | ||
|
|
def798bf1f | ||
|
|
5290834b8b | ||
|
|
89064a9d5b | ||
|
|
8c2aef3734 | ||
|
|
3fb9e542b6 | ||
|
|
01844d8687 | ||
|
|
2655425fbe | ||
|
|
bd15b630b0 | ||
|
|
fe5ce68436 | ||
|
|
0541b05966 | ||
|
|
13cb0aa9be | ||
|
|
a048369b38 | ||
|
|
9ae0c263dc | ||
|
|
a4e66f6459 | ||
|
|
2a74a8d6ae | ||
|
|
d31f25c8df | ||
|
|
11c05ea8db | ||
|
|
2b8bd1cc71 | ||
|
|
9148e02679 | ||
|
|
fd15284d91 | ||
|
|
8c7a0ec027 | ||
|
|
a1cef5c9bf | ||
|
|
90438cec36 | ||
|
|
95dd19f4d7 | ||
|
|
c64eb58cf8 | ||
|
|
fbd3d7ae3a | ||
|
|
40c7b0f731 | ||
|
|
cadcf10047 | ||
|
|
3e8f47fd97 | ||
|
|
b11ae55c6e | ||
|
|
2d63d528c6 | ||
|
|
10f253015d | ||
|
|
b34ebf85a6 | ||
|
|
06d3298cde | ||
|
|
614621ab7b | ||
|
|
8600d0a8e7 | ||
|
|
b83e6a53be | ||
|
|
88132dff8a | ||
|
|
2dc5999583 | ||
|
|
73461814c9 | ||
|
|
210e5e50d3 | ||
|
|
4fd488b97a | ||
|
|
422a34ead4 | ||
|
|
02a1036d63 | ||
|
|
2d837c9cb4 | ||
|
|
2ded774747 | ||
|
|
d9a630b8c1 | ||
|
|
b8df0dbd7f | ||
|
|
298437f352 | ||
|
|
94d72c378c | ||
|
|
f09ba6a0e3 | ||
|
|
1eda076b93 | ||
|
|
d6c10763a8 | ||
|
|
9df50d2cab | ||
|
|
6c6b510a0a | ||
|
|
063dc6fe97 | ||
|
|
42caae1bcf | ||
|
|
aa09a27a63 | ||
|
|
96e32a10e2 | ||
|
|
9a9f0eaa7d | ||
|
|
f5dea3c64c | ||
|
|
e213046302 | ||
|
|
41d31d77d8 | ||
|
|
6fb7fc80cc | ||
|
|
7bee5ff2f8 | ||
|
|
afe82ebdfd | ||
|
|
65c10ea54b | ||
|
|
ff0023c6c2 | ||
|
|
0e17d869ab | ||
|
|
7ec41bb91a | ||
|
|
da164c214e | ||
|
|
32a5de9bbb | ||
|
|
1b12b1fc35 | ||
|
|
caa1ed9d6a | ||
|
|
05f40e72ff | ||
|
|
27fb22d7be | ||
|
|
ca504384d2 | ||
|
|
b7e1e43fbd | ||
|
|
deabb19389 | ||
|
|
809035daac | ||
|
|
1eac87b89f | ||
|
|
70a2d137f0 | ||
|
|
c72b785c1f | ||
|
|
8588199640 | ||
|
|
2e42cd2faf | ||
|
|
7b3555af45 | ||
|
|
e12a77ca05 | ||
|
|
9ce3ad8300 | ||
|
|
1f60d9c3d6 | ||
|
|
d855d29c15 | ||
|
|
18083e9160 | ||
|
|
7f9e8ecac1 | ||
|
|
995c852f0a | ||
|
|
682962cc47 | ||
|
|
24e90a7f9b | ||
|
|
6a5a7182db | ||
|
|
c581c8e809 | ||
|
|
ffd2423920 | ||
|
|
c388339bd5 | ||
|
|
28492a62bb | ||
|
|
6a687ebeeb | ||
|
|
29dfae1518 | ||
|
|
791877d391 | ||
|
|
8fd0c3cc18 | ||
|
|
10dd8c86d0 | ||
|
|
c2574bdd3a | ||
|
|
d2d7892325 | ||
|
|
6d858475d7 | ||
|
|
59d55b382d | ||
|
|
8c17e55913 | ||
|
|
af509fe61f | ||
|
|
87e2a2099a | ||
|
|
3f22f62332 | ||
|
|
d1ee5f931a | ||
|
|
35506dd2bb | ||
|
|
2f06321ebf | ||
|
|
023281ae56 | ||
|
|
50dff55217 | ||
|
|
3204292360 | ||
|
|
e0d72969e3 | ||
|
|
a65b7ad413 | ||
|
|
45df44e01b | ||
|
|
d8addb105a | ||
|
|
f17ccad665 | ||
|
|
120ceb0b55 | ||
|
|
8a6f80a181 | ||
|
|
b19e468668 | ||
|
|
aeac79e1b3 | ||
|
|
b89a240250 | ||
|
|
13f42857f5 | ||
|
|
61f3f31edc | ||
|
|
3663d9dc10 | ||
|
|
89ec86c530 | ||
|
|
d9ba2a17ff | ||
|
|
c4ea6188f9 | ||
|
|
5d9f6ec763 | ||
|
|
b73847f1a6 | ||
|
|
d6e1e79f07 | ||
|
|
525008b8b2 | ||
|
|
bbf77bac4c | ||
|
|
f4ae829f59 | ||
|
|
3af8c13fab | ||
|
|
a8f7924867 | ||
|
|
77047e87d6 | ||
|
|
24d865bcd3 | ||
|
|
81ec7c201c | ||
|
|
fc6e414be4 | ||
|
|
e60cb6ad0e | ||
|
|
c90f2d6a12 | ||
|
|
fe8a738cd7 | ||
|
|
604cc53973 | ||
|
|
195b694ecc | ||
|
|
ee2d4e3ab9 | ||
|
|
d21f23beee | ||
|
|
558587883b | ||
|
|
2e6a1daf4f | ||
|
|
1fc5e75f93 | ||
|
|
a332206ba3 | ||
|
|
8e620dc635 | ||
|
|
c9a21ebace | ||
|
|
a05cdcac50 | ||
|
|
ecfb2bfb34 | ||
|
|
e17dba0a98 | ||
|
|
6b138943ce | ||
|
|
eb0e6aff68 | ||
|
|
4d0095626a | ||
|
|
aa0a501ade | ||
|
|
68ef7bd2c4 | ||
|
|
61dc5de085 | ||
|
|
63bdd71e22 | ||
|
|
9ea5b50802 | ||
|
|
1cd586634d | ||
|
|
45bedbe70e | ||
|
|
f7f1dde7b5 | ||
|
|
ba06555078 | ||
|
|
840fa39979 | ||
|
|
b295416e6c | ||
|
|
914f77ff37 | ||
|
|
b0b7b914d8 | ||
|
|
12713aad45 | ||
|
|
02e12cc1e4 | ||
|
|
61f08f3218 | ||
|
|
75c2a063cc | ||
|
|
b4773c4e48 | ||
|
|
fb73da8735 | ||
|
|
679e549b1d | ||
|
|
898144e9f4 | ||
|
|
b99c5561fc | ||
|
|
b2f4b91979 | ||
|
|
4528000fc4 | ||
|
|
96e40eaf25 | ||
|
|
197258ae91 | ||
|
|
19f417174c | ||
|
|
9c82eeddeb | ||
|
|
f11e01b549 | ||
|
|
863b26c3fa | ||
|
|
b788858f9e | ||
|
|
de8a7df6c2 | ||
|
|
ba5b481617 | ||
|
|
07ad846e96 | ||
|
|
30945aafdd | ||
|
|
24c15b4479 | ||
|
|
1d4c5bbdf1 | ||
|
|
57fcec011d | ||
|
|
455e3db28d | ||
|
|
8caab43b00 | ||
|
|
7479545339 | ||
|
|
10ee30695a | ||
|
|
a9a262eaae | ||
|
|
a8594b76cd | ||
|
|
11ee0fef5d | ||
|
|
9a9ba34717 | ||
|
|
312e47bf46 | ||
|
|
628865fd06 | ||
|
|
806a03cd53 | ||
|
|
24bd90fcf6 | ||
|
|
d2765577c8 | ||
|
|
60ca688bcb | ||
|
|
76d8eea41d | ||
|
|
635c3a04d8 | ||
|
|
dde97abe38 | ||
|
|
90a22d894d | ||
|
|
88ef9cd6ae | ||
|
|
e3595b5c57 | ||
|
|
ce82f87e43 | ||
|
|
854b291c5a | ||
|
|
9780fd059c | ||
|
|
adc65f66eb | ||
|
|
ae772074a1 | ||
|
|
16c1e9edd1 | ||
|
|
3ab9ffb7b7 | ||
|
|
82e2123fe7 | ||
|
|
7a65f3d2f4 | ||
|
|
b5b5d499e5 | ||
|
|
173f9e9c30 | ||
|
|
a610c72067 | ||
|
|
d210a49fae | ||
|
|
b015c248ea | ||
|
|
4a559ea770 | ||
|
|
e306751863 | ||
|
|
2f51f5f33e | ||
|
|
74a2a61fc1 | ||
|
|
b6c0345b3e | ||
|
|
6421a6f5cb | ||
|
|
daf56e5dc2 | ||
|
|
cb7c9af25c | ||
|
|
45e61befac | ||
|
|
ea50ba10e6 | ||
|
|
5c4a727e74 | ||
|
|
867f05c4ad | ||
|
|
b06b32306f | ||
|
|
dbfcb70f8d | ||
|
|
e64d56c4ac | ||
|
|
8f0da7943c | ||
|
|
e62ff7e520 | ||
|
|
86e951916e | ||
|
|
6bf08466de | ||
|
|
5e36dd480d | ||
|
|
0e2cd8c018 | ||
|
|
b4f92eba38 | ||
|
|
905e48c8ed | ||
|
|
10ec79312e | ||
|
|
24f779ff95 | ||
|
|
08c0677de9 | ||
|
|
cc5d32cf8a | ||
|
|
01a5133396 | ||
|
|
0aa5188b29 | ||
|
|
e49a161d0a | ||
|
|
0ddc3d60e7 | ||
|
|
51794176af | ||
|
|
b634aa48dc | ||
|
|
16ae8ac546 | ||
|
|
1ecb0735cb | ||
|
|
c368d828c9 | ||
|
|
019ae9c216 | ||
|
|
580d9441a4 | ||
|
|
b5d192425e | ||
|
|
58312deb8c | ||
|
|
cf646752c5 | ||
|
|
b53750fde4 | ||
|
|
52e6135ae8 | ||
|
|
f4eb59e2ad | ||
|
|
34d84590e2 | ||
|
|
d09b823c49 | ||
|
|
348620ac0a | ||
|
|
a8481e43f0 | ||
|
|
3c04eeaff9 | ||
|
|
87131cf03b | ||
|
|
7d51293594 | ||
|
|
b78b0e50bb | ||
|
|
6b4c1a7dee | ||
|
|
2e1f16d7b4 | ||
|
|
50c33c5213 | ||
|
|
ace6d62d76 | ||
|
|
b7c4c21796 | ||
|
|
66602da9cb | ||
|
|
31b483509c | ||
|
|
ba7cf69c9d | ||
|
|
37296be67e | ||
|
|
6c03a1dd31 | ||
|
|
b75ec9e989 | ||
|
|
5c8523e4ef | ||
|
|
9802a42a9e | ||
|
|
99e3abec72 | ||
|
|
fc2efdf994 | ||
|
|
6ed672d996 | ||
|
|
2bf593fa6b | ||
|
|
3182214663 | ||
|
|
20614b20b7 | ||
|
|
da323817f7 | ||
|
|
763c1a885c | ||
|
|
dbc09f46f4 | ||
|
|
cf43f09aff | ||
|
|
c3c51b0fbf | ||
|
|
8a42daa63f | ||
|
|
d91d98c9d4 | ||
|
|
2e82f2b2d1 | ||
|
|
f459c7017a | ||
|
|
c27ccb8475 | ||
|
|
abb2f7ae05 | ||
|
|
80606ed32c | ||
|
|
bc7c5fa864 | ||
|
|
ed0ea68037 | ||
|
|
6ac4dbc011 | ||
|
|
e642ffa5b3 |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.github
|
||||||
|
.venv
|
||||||
|
.vscode
|
||||||
|
.data
|
||||||
|
.temp
|
||||||
|
web/.next
|
||||||
|
web/node_modules
|
||||||
|
web/.env
|
||||||
13
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
13
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
name: 漏洞反馈
|
name: 漏洞反馈
|
||||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://docs.langbot.app/zh/workshop/network-details.html
|
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
@@ -10,6 +10,15 @@ body:
|
|||||||
placeholder: 例如:v3.3.0、CentOS x64 Python 3.10.3、Docker
|
placeholder: 例如:v3.3.0、CentOS x64 Python 3.10.3、Docker
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: 部署版本
|
||||||
|
description: 请选择您使用的 LangBot 部署版本。
|
||||||
|
options:
|
||||||
|
- 社区版
|
||||||
|
- 云服务
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 异常情况
|
label: 异常情况
|
||||||
@@ -19,7 +28,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 复现步骤
|
label: 复现步骤
|
||||||
description: 提供越多信息,我们会越快解决问题,建议多提供配置截图;**如果你不认真填写(只一两句话概括),我们会很生气并且立即关闭 issue 或两年后才回复你**
|
description: 提供越多信息,我们会越快解决问题,建议多提供配置截图;**如果涉及 Dify、n8n、Langflow 等外部平台,请提供应用的导出文件(如 Dify 应用的 DSL),我们将更快回复您。**
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
13
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
13
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://docs.langbot.app/en/workshop/network-details.html
|
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
@@ -10,6 +10,15 @@ body:
|
|||||||
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker"
|
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Deployment version
|
||||||
|
description: Please select the LangBot deployment version you are using.
|
||||||
|
options:
|
||||||
|
- Community Edition
|
||||||
|
- Cloud Service
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Exception
|
label: Exception
|
||||||
@@ -19,7 +28,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Reproduction steps
|
label: Reproduction steps
|
||||||
description: How to reproduce this problem, the more detailed the better; the more information you provide, the faster we will solve the problem. 【注意】请务必认真填写此部分,若不提供完整信息(如只有一两句话的概括),我们将不会回复!
|
description: How to reproduce this problem, the more detailed the better; the more information you provide, the faster we will solve the problem.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
12
.github/pull_request_template.md
vendored
12
.github/pull_request_template.md
vendored
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
> 请在此部分填写你实现/解决/优化的内容:
|
> 请在此部分填写你实现/解决/优化的内容:
|
||||||
> Summary of what you implemented/solved/optimized:
|
> Summary of what you implemented/solved/optimized:
|
||||||
|
>
|
||||||
|
|
||||||
|
### 更改前后对比截图 / Screenshots
|
||||||
|
|
||||||
|
> 请在此部分粘贴更改前后对比截图(可以是界面截图、控制台输出、对话截图等):
|
||||||
|
> Please paste the screenshots of changes before and after here (can be interface screenshots, console output, conversation screenshots, etc.):
|
||||||
|
>
|
||||||
|
> 修改前 / Before:
|
||||||
|
>
|
||||||
|
> 修改后 / After:
|
||||||
|
>
|
||||||
|
|
||||||
## 检查清单 / Checklist
|
## 检查清单 / Checklist
|
||||||
|
|
||||||
@@ -10,6 +21,7 @@
|
|||||||
*请在方括号间写`x`以打勾 / Please tick the box with `x`*
|
*请在方括号间写`x`以打勾 / Please tick the box with `x`*
|
||||||
|
|
||||||
- [ ] 阅读仓库[贡献指引](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)了吗? / Have you read the [contribution guide](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)?
|
- [ ] 阅读仓库[贡献指引](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)了吗? / Have you read the [contribution guide](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)?
|
||||||
|
- [ ] 我已签署或将在机器人提示后签署 [CLA](https://github.com/langbot-app/LangBot/blob/master/CLA.md)。 / I have signed, or will sign when prompted by the bot, the [CLA](https://github.com/langbot-app/LangBot/blob/master/CLA.md).
|
||||||
- [ ] 与项目所有者沟通过了吗? / Have you communicated with the project maintainer?
|
- [ ] 与项目所有者沟通过了吗? / Have you communicated with the project maintainer?
|
||||||
- [ ] 我确定已自行测试所作的更改,确保功能符合预期。 / I have tested the changes and ensured they work as expected.
|
- [ ] 我确定已自行测试所作的更改,确保功能符合预期。 / I have tested the changes and ensured they work as expected.
|
||||||
|
|
||||||
|
|||||||
5
.github/workflows/build-docker-image.yml
vendored
5
.github/workflows/build-docker-image.yml
vendored
@@ -3,7 +3,6 @@ on:
|
|||||||
## 发布release的时候会自动构建
|
## 发布release的时候会自动构建
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
workflow_dispatch:
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-docker-image:
|
publish-docker-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -42,7 +41,7 @@ jobs:
|
|||||||
run: docker buildx create --name mybuilder --use
|
run: docker buildx create --name mybuilder --use
|
||||||
- name: Build for Release # only relase, exlude pre-release
|
- name: Build for Release # only relase, exlude pre-release
|
||||||
if: ${{ github.event.release.prerelease == false }}
|
if: ${{ github.event.release.prerelease == false }}
|
||||||
run: docker buildx build --platform linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push
|
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push
|
||||||
- name: Build for Pre-release # no update for latest tag
|
- name: Build for Pre-release # no update for latest tag
|
||||||
if: ${{ github.event.release.prerelease == true }}
|
if: ${{ github.event.release.prerelease == true }}
|
||||||
run: docker buildx build --platform linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push
|
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push
|
||||||
@@ -43,10 +43,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd /tmp/langbot_build_web/web
|
cd /tmp/langbot_build_web/web
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npx vite build
|
||||||
- name: Package Output
|
- name: Package Output
|
||||||
run: |
|
run: |
|
||||||
cp -r /tmp/langbot_build_web/web/out ./web
|
cp -r /tmp/langbot_build_web/web/dist ./web
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
25
.github/workflows/check-i18n.yml
vendored
Normal file
25
.github/workflows/check-i18n.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: Check i18n Keys
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-i18n:
|
||||||
|
name: Check i18n Key Consistency
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Check i18n keys against en-US reference
|
||||||
|
run: node web/scripts/check-i18n.mjs
|
||||||
41
.github/workflows/cla.yml
vendored
Normal file
41
.github/workflows/cla.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: "CLA Assistant"
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, closed, synchronize, reopened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: write # re-run the failed CLA check after signing
|
||||||
|
contents: read # signatures are stored in the remote langbot-app/cla repo
|
||||||
|
pull-requests: write # post guidance comments, lock PR after merge
|
||||||
|
statuses: write # set the commit status
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
CLAAssistant:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: "CLA Assistant"
|
||||||
|
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||||
|
# Upstream repo was archived in 2026-03; pin to the v2.6.1 commit SHA.
|
||||||
|
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# repo-scope PAT with write access to langbot-app/cla
|
||||||
|
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
|
||||||
|
with:
|
||||||
|
path-to-document: 'https://github.com/langbot-app/LangBot/blob/master/CLA.md'
|
||||||
|
remote-organization-name: 'langbot-app'
|
||||||
|
remote-repository-name: 'cla'
|
||||||
|
path-to-signatures: 'signatures/version1/cla.json'
|
||||||
|
branch: 'main'
|
||||||
|
allowlist: 'dependabot[bot],github-actions[bot],devin-ai-integration[bot],Copilot,renovate[bot],bot*'
|
||||||
|
custom-notsigned-prcomment: |
|
||||||
|
Thank you for your contribution! :heart: Before we can merge this pull request, we need you to sign the [LangBot Contributor License Agreement (CLA)](https://github.com/langbot-app/LangBot/blob/master/CLA.md). You keep full copyright of your code — the CLA grants us a license to use and distribute your contribution. Signing takes 10 seconds and covers all repositories in this organization, permanently.
|
||||||
|
|
||||||
|
感谢您的贡献!合并前请阅读并签署[贡献者许可协议(CLA)](https://github.com/langbot-app/LangBot/blob/master/CLA.md)。您保留代码的全部版权,签署仅需回复下方指定内容,一次签署对本组织全部仓库永久有效。
|
||||||
|
custom-allsigned-prcomment: 'All contributors have signed the CLA. :white_check_mark: 所有贡献者均已签署 CLA。'
|
||||||
|
lock-pullrequest-aftermerge: true
|
||||||
|
# SECURITY: this workflow runs on pull_request_target (it holds secrets and has
|
||||||
|
# write access to the base repository). NEVER add an actions/checkout step that
|
||||||
|
# checks out the PR's code here.
|
||||||
60
.github/workflows/lint.yml
vendored
Normal file
60
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- dev
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ruff:
|
||||||
|
name: Ruff Lint & Format
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --dev
|
||||||
|
|
||||||
|
- name: Run ruff check
|
||||||
|
run: uv run ruff check src
|
||||||
|
|
||||||
|
- name: Run ruff format
|
||||||
|
run: uv run ruff format src --check
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
name: Frontend Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '25'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: web
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Run lint
|
||||||
|
working-directory: web
|
||||||
|
run: pnpm lint
|
||||||
4
.github/workflows/publish-to-pypi.yml
vendored
4
.github/workflows/publish-to-pypi.yml
vendored
@@ -29,8 +29,8 @@ jobs:
|
|||||||
npm install -g pnpm
|
npm install -g pnpm
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
mkdir -p ../src/langbot/web/out
|
mkdir -p ../src/langbot/web/dist
|
||||||
cp -r out ../src/langbot/web/
|
cp -r dist ../src/langbot/web/
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
|
|||||||
117
.github/workflows/run-tests.yml
vendored
117
.github/workflows/run-tests.yml
vendored
@@ -4,29 +4,29 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, ready_for_review, synchronize]
|
types: [opened, ready_for_review, synchronize]
|
||||||
paths:
|
paths:
|
||||||
- 'pkg/**'
|
- 'src/langbot/**'
|
||||||
- 'tests/**'
|
- 'tests/**'
|
||||||
- '.github/workflows/run-tests.yml'
|
- '.github/workflows/run-tests.yml'
|
||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
|
- 'uv.lock'
|
||||||
- 'run_tests.sh'
|
- 'run_tests.sh'
|
||||||
|
- 'scripts/test-*.sh'
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- develop
|
- develop
|
||||||
paths:
|
- 'feat/**'
|
||||||
- 'pkg/**'
|
# No path filter on push: every push to the branches above runs the
|
||||||
- 'tests/**'
|
# full unit-test suite. feat/** branches in particular must be tested
|
||||||
- '.github/workflows/run-tests.yml'
|
# on every push (they accumulate large changes before a PR exists).
|
||||||
- 'pyproject.toml'
|
|
||||||
- 'run_tests.sh'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Unit Tests
|
name: Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.10', '3.11', '3.12']
|
python-version: ['3.11', '3.12', '3.13']
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -39,28 +39,13 @@ jobs:
|
|||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
run: |
|
uses: astral-sh/setup-uv@v4
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: uv sync --dev
|
||||||
uv sync --dev
|
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit + smoke tests
|
||||||
run: |
|
run: uv run pytest tests/unit_tests/ tests/smoke/ -q --tb=short
|
||||||
bash run_tests.sh
|
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
|
||||||
if: matrix.python-version == '3.12'
|
|
||||||
uses: codecov/codecov-action@v5
|
|
||||||
with:
|
|
||||||
files: ./coverage.xml
|
|
||||||
flags: unit-tests
|
|
||||||
name: unit-tests-coverage
|
|
||||||
fail_ci_if_error: false
|
|
||||||
env:
|
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
|
|
||||||
- name: Test Summary
|
- name: Test Summary
|
||||||
if: always()
|
if: always()
|
||||||
@@ -69,3 +54,79 @@ jobs:
|
|||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Python Version: ${{ matrix.python-version }}" >> $GITHUB_STEP_SUMMARY
|
echo "Python Version: ${{ matrix.python-version }}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
integration:
|
||||||
|
name: Fast Integration Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --dev
|
||||||
|
|
||||||
|
- name: Run fast integration tests
|
||||||
|
run: uv run pytest tests/integration/ -m "not slow" -q --tb=short
|
||||||
|
|
||||||
|
- name: Integration Test Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "## Integration Tests Results" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
name: Coverage Gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test, integration]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --dev
|
||||||
|
|
||||||
|
- name: Run coverage (unit + smoke)
|
||||||
|
run: |
|
||||||
|
uv run pytest tests/unit_tests/ tests/smoke/ \
|
||||||
|
--cov=langbot \
|
||||||
|
--cov-report=xml \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
--cov-fail-under=18 \
|
||||||
|
-q --tb=short
|
||||||
|
|
||||||
|
- name: Upload coverage to Codecov
|
||||||
|
uses: codecov/codecov-action@v5
|
||||||
|
with:
|
||||||
|
files: ./coverage.xml
|
||||||
|
flags: unit-tests
|
||||||
|
name: coverage-report
|
||||||
|
fail_ci_if_error: false
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
- name: Coverage Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "## Coverage Results" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Threshold: 18%" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
||||||
78
.github/workflows/test-migrations.yml
vendored
Normal file
78
.github/workflows/test-migrations.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: Test Migrations
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- dev
|
||||||
|
paths:
|
||||||
|
- 'src/langbot/pkg/persistence/**'
|
||||||
|
- 'src/langbot/pkg/entity/persistence/**'
|
||||||
|
- 'tests/integration/persistence/**'
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
paths:
|
||||||
|
- 'src/langbot/pkg/persistence/**'
|
||||||
|
- 'src/langbot/pkg/entity/persistence/**'
|
||||||
|
- 'tests/integration/persistence/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-migrations-sqlite:
|
||||||
|
name: Migrations (SQLite)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --dev
|
||||||
|
|
||||||
|
- name: Run SQLite migration tests
|
||||||
|
run: uv run pytest tests/integration/persistence/test_migrations.py -q --tb=short
|
||||||
|
|
||||||
|
test-migrations-postgres:
|
||||||
|
name: Migrations (PostgreSQL)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: langbot
|
||||||
|
POSTGRES_PASSWORD: langbot
|
||||||
|
POSTGRES_DB: langbot_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd="pg_isready -U langbot"
|
||||||
|
--health-interval=5s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --dev
|
||||||
|
|
||||||
|
- name: Run PostgreSQL migration tests
|
||||||
|
env:
|
||||||
|
TEST_POSTGRES_URL: postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test
|
||||||
|
run: uv run pytest tests/integration/persistence/test_migrations_postgres.py -q --tb=short
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -42,14 +42,17 @@ botpy.log*
|
|||||||
test.py
|
test.py
|
||||||
/web_ui
|
/web_ui
|
||||||
.venv/
|
.venv/
|
||||||
uv.lock
|
|
||||||
/test
|
/test
|
||||||
plugins.bak
|
plugins.bak
|
||||||
coverage.xml
|
coverage.xml
|
||||||
.coverage
|
.coverage
|
||||||
src/langbot/web/
|
src/langbot/web/
|
||||||
|
testsdk/
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
/dist
|
/dist
|
||||||
/build
|
/build
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
|
# Next.js build cache (legacy)
|
||||||
|
web/.next/
|
||||||
|
|||||||
37
.mcp.json
Normal file
37
.mcp.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"shadcn": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"shadcn@latest",
|
||||||
|
"mcp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sequential-thinking": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"],
|
||||||
|
"env": {}
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||||
|
"env": {
|
||||||
|
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fetch": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["mcp-server-fetch"],
|
||||||
|
"env": {}
|
||||||
|
},
|
||||||
|
"playwright": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@playwright/mcp@latest"],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,16 +9,14 @@ repos:
|
|||||||
# Run the formatter of backend.
|
# Run the formatter of backend.
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
|
||||||
rev: v3.1.0
|
|
||||||
hooks:
|
|
||||||
- id: prettier
|
|
||||||
types_or: [javascript, jsx, ts, tsx, css, scss]
|
|
||||||
additional_dependencies:
|
|
||||||
- prettier@3.1.0
|
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
|
- id: prettier
|
||||||
|
name: prettier
|
||||||
|
entry: npx --prefix web prettier --write --ignore-unknown
|
||||||
|
language: system
|
||||||
|
types_or: [javascript, jsx, ts, tsx, css, scss]
|
||||||
|
|
||||||
- id: lint-staged
|
- id: lint-staged
|
||||||
name: lint-staged
|
name: lint-staged
|
||||||
entry: cd web && pnpm lint-staged
|
entry: cd web && pnpm lint-staged
|
||||||
|
|||||||
141
AGENTS.md
141
AGENTS.md
@@ -1,79 +1,134 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
This file is for guiding code agents (like Claude Code, GitHub Copilot, OpenAI Codex, etc.) to work in LangBot project.
|
This file guides code agents (Claude Code, GitHub Copilot, OpenAI Codex, etc.) working in the LangBot project. `CLAUDE.md` is a symlink to this file.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
LangBot is a open-source LLM native instant messaging bot development platform, aiming to provide an out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, supporting global instant messaging platforms, and providing rich API interfaces, supporting custom development.
|
LangBot is an open-source, LLM-native instant-messaging bot development platform. It aims to provide an out-of-the-box IM bot development experience with Agent, RAG, MCP and other LLM application capabilities, supporting mainstream global IM platforms and exposing rich APIs for custom development.
|
||||||
|
|
||||||
LangBot has a comprehensive frontend, all operations can be performed through the frontend. The project splited into these major parts:
|
LangBot has a comprehensive web frontend — almost every operation can be performed through it.
|
||||||
|
|
||||||
- `./pkg`: The core python package of the project backend.
|
- **Python**: `>=3.11,<4.0`, dependencies managed by `uv`. Package version is in `pyproject.toml`.
|
||||||
- `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc.
|
- **Frontend**: `web/` is a **Vite + React Router 7 + shadcn/ui + Tailwind CSS** SPA, managed by `pnpm`. (Note: this is NOT Next.js — the `dev` script is `vite`.)
|
||||||
- `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc.
|
- **Backend framework**: Quart (the async flavour of Flask). The HTTP API and the pre-built web UI are both served by the backend on `http://127.0.0.1:5300`.
|
||||||
- `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc.
|
|
||||||
- `./pkg/api`: The api module of the project, containing the http api controllers and services.
|
|
||||||
- `./pkg/plugin`: LangBot bridge for connecting with plugin system.
|
|
||||||
- `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc.
|
|
||||||
- `./templates`: Templates of config files, components, etc.
|
|
||||||
- `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**.
|
|
||||||
- `./docker`: docker-compose deployment files.
|
|
||||||
|
|
||||||
## Backend Development
|
## Repository Layout
|
||||||
|
|
||||||
We use `uv` to manage dependencies.
|
```
|
||||||
|
LangBot/
|
||||||
|
├── main.py # Entrypoint shim -> langbot.__main__.main()
|
||||||
|
├── pyproject.toml # Python project + deps (uv), pins langbot-plugin==<x.y.z>
|
||||||
|
├── src/langbot/
|
||||||
|
│ ├── __main__.py # Real entrypoint, CLI args (--standalone-runtime, --standalone-box, --debug)
|
||||||
|
│ ├── pkg/ # Core backend package
|
||||||
|
│ │ ├── api/ # HTTP API controllers + services (Quart)
|
||||||
|
│ │ ├── core/ # App bootstrap, stages, task manager
|
||||||
|
│ │ ├── platform/ # IM platform adapters, bot managers, session managers
|
||||||
|
│ │ ├── provider/ # LLM providers, requesters, tool providers
|
||||||
|
│ │ ├── pipeline/ # Pipelines, stages, query pool
|
||||||
|
│ │ ├── plugin/ # Bridge connecting LangBot to the plugin runtime (see below)
|
||||||
|
│ │ ├── box/ # Code-sandbox subsystem (Docker / nsjail / E2B backends)
|
||||||
|
│ │ ├── skill/ # Skill subsystem
|
||||||
|
│ │ ├── rag/ , vector/ # RAG + vector store
|
||||||
|
│ │ ├── command/ # Built-in commands
|
||||||
|
│ │ ├── persistence/ # ORM models + Alembic migrations (SQLite & PostgreSQL)
|
||||||
|
│ │ ├── storage/ # Object/file storage abstractions
|
||||||
|
│ │ ├── config/, entity/, discover/, utils/, telemetry/, survey/
|
||||||
|
│ ├── libs/ # Vendored SDKs (qq_official_api, wecom_api, etc.)
|
||||||
|
│ └── templates/ # Config/component templates (e.g. templates/config.yaml)
|
||||||
|
├── web/ # Frontend SPA (Vite + React Router 7 + shadcn + Tailwind)
|
||||||
|
└── docker/ # docker-compose deployment files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Environment Setup
|
||||||
|
|
||||||
|
Full guide lives in the wiki: **["开发配置" / Dev Config](https://docs.langbot.app/zh/develop/dev-config)**. Summary:
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install uv
|
pip install uv
|
||||||
uv sync --dev
|
uv sync --dev # uv creates a .venv/ for you; point your editor's interpreter at it
|
||||||
|
uv run main.py # serves API + web UI on http://127.0.0.1:5300
|
||||||
```
|
```
|
||||||
|
|
||||||
Start the backend and run the project in development mode.
|
On first run the config file is generated at `data/config.yaml`. DB is SQLite by default (zero setup); PostgreSQL is supported. Migrations run automatically on startup.
|
||||||
|
|
||||||
```bash
|
### Frontend
|
||||||
uv run main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Then you can access the project at `http://127.0.0.1:5300`.
|
Requires Node.js + [pnpm](https://pnpm.io/installation).
|
||||||
|
|
||||||
## Frontend Development
|
|
||||||
|
|
||||||
We use `pnpm` to manage dependencies.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web
|
cd web
|
||||||
cp .env.example .env
|
cp .env.example .env # Windows: copy .env.example .env
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm dev
|
pnpm dev # http://127.0.0.1:3000 (npm install / npm run dev also work)
|
||||||
```
|
```
|
||||||
|
|
||||||
Then you can access the project at `http://127.0.0.1:3000`.
|
`pnpm dev` reads `VITE_API_BASE_URL` from `web/.env` so the dev frontend can reach the backend on port `5300`. In production the frontend is pre-built into static files served by the backend on the same origin.
|
||||||
|
|
||||||
## Plugin System Architecture
|
### Code formatting
|
||||||
|
|
||||||
LangBot is composed of various internal components such as Large Language Model tools, commands, messaging platform adapters, LLM requesters, and more. To meet extensibility and flexibility requirements, we have implemented a production-grade plugin system.
|
The repo runs lint + format checks in CI. Install the pre-commit hooks so the same checks run locally before each commit:
|
||||||
|
|
||||||
Each plugin runs in an independent process, managed uniformly by the Plugin Runtime. It has two operating modes: `stdio` and `websocket`. When LangBot is started directly by users (not running in a container), it uses `stdio` mode, which is common for personal users or lightweight environments. When LangBot runs in a container, it uses `websocket` mode, designed specifically for production environments.
|
```bash
|
||||||
|
uv run pre-commit install
|
||||||
|
```
|
||||||
|
|
||||||
Plugin Runtime automatically starts each installed plugin and interacts through stdio. In plugin development scenarios, developers can use the lbp command-line tool to start plugins and connect to the running Runtime via WebSocket for debugging.
|
## Plugin System
|
||||||
|
|
||||||
> Plugin SDK, CLI, Runtime, and entities definitions shared between LangBot and plugins are contained in the [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk) repository.
|
LangBot's plugin system (Plugin SDK, CLI `lbp`, Plugin Runtime, and the shared entity/API definitions) lives in a **separate repository**: [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk). LangBot depends on it via the pinned `langbot-plugin` package in `pyproject.toml`.
|
||||||
|
|
||||||
## Some Development Tips and Standards
|
### Architecture (what to know inside this repo)
|
||||||
|
|
||||||
- LangBot is a global project, any comments in code should be in English, and user experience should be considered in all aspects.
|
- Plugins run as independent processes managed by the **Plugin Runtime**. The Runtime supports two control transports: `stdio` and `websocket`.
|
||||||
- Thus you should consider the i18n support in all aspects.
|
- When LangBot is started directly by a user (not in a container), it spawns and connects to the Runtime over **stdio** (lightweight/personal use).
|
||||||
- LangBot is widely adopted in both toC and toB scenarios, so you should consider the compatibility and security in all aspects.
|
- When LangBot runs in a container, it connects to a standalone Runtime over **WebSocket** (production).
|
||||||
- If you were asked to make a commit, please follow the commit message format:
|
- The bridge code lives in `src/langbot/pkg/plugin/` (`connector.py`, `handler.py`).
|
||||||
- format: <type>(<scope>): <subject>
|
- Relevant config (`data/config.yaml`): `plugin.runtime_ws_url` (e.g. `ws://langbot_plugin_runtime:5400/control/ws`). Start LangBot with `--standalone-runtime` to make it connect to an externally-launched Runtime over WebSocket instead of spawning one over stdio.
|
||||||
- type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc.
|
|
||||||
- scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc.
|
### Debugging the Plugin Runtime / CLI / SDK
|
||||||
- subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc.
|
|
||||||
|
This is documented in detail in the **SDK repo's `AGENTS.md`** and in the wiki page **["调试插件运行时、CLI、SDK" / Plugin Runtime](https://docs.langbot.app/zh/develop/plugin-runtime)**. The short version:
|
||||||
|
|
||||||
|
- Clone `LangBot` and `langbot-plugin-sdk` as siblings under one parent dir so the editor resolves shared entities.
|
||||||
|
- Start a standalone Runtime from the SDK repo: `uv run --no-sync lbp rt` (control port `5400`, debug port `5401`).
|
||||||
|
- To make LangBot use a locally-modified SDK: from the SDK dir, with LangBot's `.venv` active, run `uv pip install .`, then launch LangBot with `uv run --no-sync main.py --standalone-runtime` (keep `--no-sync` so your local SDK isn't overwritten).
|
||||||
|
|
||||||
|
### Debugging the Box (sandbox) runtime
|
||||||
|
|
||||||
|
The Box subsystem (`src/langbot/pkg/box/`) is the code sandbox. It picks the first available backend among **Docker / nsjail / E2B**. The standalone Box runtime is launched via the SDK CLI: `lbp box`. Backend selection details, the `lbp box` flags, and the SDK-side architecture are documented in the SDK repo's `AGENTS.md`.
|
||||||
|
|
||||||
|
Relevant config (`data/config.yaml`, `box:` section): `box.enabled` (master switch — disabling it also disables the native sandbox tools, skill add/edit, and stdio-mode MCP servers), `box.backend` (`'local'` = Docker/nsjail auto-pick, or `'docker'` / `'nsjail'` / `'e2b'`; also settable via `BOX__BACKEND`), and `box.runtime.endpoint` (external Box runtime base URL, e.g. `ws://127.0.0.1:5410`; empty = local auto-managed runtime). Like the plugin runtime, LangBot can connect to an externally-launched Box runtime by setting that endpoint and starting with `--standalone-box`.
|
||||||
|
|
||||||
|
> A common false "No supported sandbox backend (Docker / nsjail / E2B) is available" comes from Docker being installed and running but the current user not being in the `docker` group → `docker info` gets `permission denied` on the socket. Fix: `sudo usermod -aG docker <user>` and restart the backend in a shell that has the new group.
|
||||||
|
|
||||||
|
## Development Standards
|
||||||
|
|
||||||
|
- LangBot is a global project: **all code comments and docstrings must be in English**, and every user-facing string must support **i18n** (`en_US` + `zh_Hans` at minimum, plus `ja_JP` where the repo already has it).
|
||||||
|
- LangBot is adopted in both toC and toB scenarios — always consider compatibility and security.
|
||||||
|
- **Commit message format**: `<type>(<scope>): <subject>`
|
||||||
|
- `type`: one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, etc.
|
||||||
|
- `scope`: the affected package/module/file/class.
|
||||||
|
- `subject`: concise description of the change.
|
||||||
|
|
||||||
|
### Database migrations (Alembic)
|
||||||
|
|
||||||
|
LangBot uses [Alembic](https://alembic.sqlalchemy.org/) for migrations, supporting both SQLite and PostgreSQL from a single set of scripts. Migration files live in `src/langbot/pkg/persistence/alembic/versions/`.
|
||||||
|
|
||||||
|
If you change ORM model definitions, generate a migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run from the project root (requires data/config.yaml to exist)
|
||||||
|
uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change"
|
||||||
|
```
|
||||||
|
|
||||||
|
Review and edit the generated script before committing. Migrations execute automatically on startup. `autogenerate` detects schema changes (add/drop columns, tables, type changes) but **data migrations** (e.g. mutating JSON field contents) must be hand-written into the generated script. `env.py` sets `render_as_batch=True`, so SQLite's ALTER TABLE limits are handled automatically — no need to branch per database. More in the wiki ["开发配置"](https://docs.langbot.app/zh/develop/dev-config#数据库迁移).
|
||||||
|
|
||||||
## Some Principles
|
## Some Principles
|
||||||
|
|
||||||
- Keep it simple, stupid.
|
- Keep it simple, stupid.
|
||||||
- Entities should not be multiplied unnecessarily
|
- Entities should not be multiplied unnecessarily.
|
||||||
- 八荣八耻
|
- 八荣八耻
|
||||||
|
|
||||||
以瞎猜接口为耻,以认真查询为荣。
|
以瞎猜接口为耻,以认真查询为荣。
|
||||||
|
|||||||
107
CLA.md
Normal file
107
CLA.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# LangBot Individual Contributor License Agreement (v1.0)
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to LangBot (the "Project"), stewarded by Beijing Langbo Intelligent Technology Co., Ltd. (北京浪波智能科技有限公司) ("We" or "Us").
|
||||||
|
|
||||||
|
This Individual Contributor License Agreement ("Agreement") documents the rights granted by contributors to Us. By signing this Agreement (see Section 9), You accept and agree to the following terms and conditions for Your present and future Contributions submitted to the Project. Except for the licenses granted herein to Us and recipients of software distributed by Us, You reserve all right, title, and interest in and to Your Contributions.
|
||||||
|
|
||||||
|
## 1. Definitions
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with Us.
|
||||||
|
|
||||||
|
"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to Us for inclusion in, or documentation of, any of the products or repositories owned or managed by Us (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to Us or our representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Us for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
|
||||||
|
|
||||||
|
## 2. Grant of Copyright License
|
||||||
|
|
||||||
|
Subject to the terms and conditions of this Agreement, You hereby grant to Us and to recipients of software distributed by Us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. For clarity, this includes the right for Us to distribute Your Contributions, alone or as part of the Work, under the terms of any license, including without limitation open source licenses and commercial or proprietary licenses.
|
||||||
|
|
||||||
|
## 3. Grant of Patent License
|
||||||
|
|
||||||
|
Subject to the terms and conditions of this Agreement, You hereby grant to Us and to recipients of software distributed by Us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that Your Contribution, or the Work to which You have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
|
||||||
|
|
||||||
|
## 4. Authority; Employer
|
||||||
|
|
||||||
|
You represent that You are legally entitled to grant the above licenses. If Your employer(s) has rights to intellectual property that You create that includes Your Contributions, You represent that You have received permission to make Contributions on behalf of that employer, that Your employer has waived such rights for Your Contributions to Us, or that Your employer has executed a separate Corporate Contributor License Agreement with Us.
|
||||||
|
|
||||||
|
## 5. Original Creation; Disclosure
|
||||||
|
|
||||||
|
You represent that each of Your Contributions is Your original creation (see Section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which You are personally aware and which are associated with any part of Your Contributions.
|
||||||
|
|
||||||
|
## 6. No Obligation of Support; Disclaimer
|
||||||
|
|
||||||
|
You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
|
||||||
|
|
||||||
|
## 7. Third-Party Works
|
||||||
|
|
||||||
|
Should You wish to submit work that is not Your original creation, You may submit it to Us separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which You are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
|
||||||
|
|
||||||
|
## 8. Notification
|
||||||
|
|
||||||
|
You agree to notify Us of any facts or circumstances of which You become aware that would make these representations inaccurate in any respect.
|
||||||
|
|
||||||
|
## 9. Electronic Signature
|
||||||
|
|
||||||
|
This Agreement is accepted and signed electronically: posting a comment containing the exact phrase designated by Us (currently "I have read the CLA Document and I hereby sign the CLA") from Your GitHub account on a pull request in the Project's repositories constitutes Your binding electronic signature to this Agreement. You represent that the GitHub account used to sign belongs to You and that You are of legal age to form a binding contract. Your signature covers Your present and future Contributions to all repositories owned or managed by Us, until and unless You notify Us in writing that You withdraw from this Agreement for future Contributions (licenses already granted are irrevocable).
|
||||||
|
|
||||||
|
## 10. Our Commitment
|
||||||
|
|
||||||
|
We commit that the Project's main repository will continue to make an open source version of the Work publicly available.
|
||||||
|
|
||||||
|
## 11. Miscellaneous
|
||||||
|
|
||||||
|
This Agreement is the entire agreement between You and Us regarding Your Contributions and supersedes any prior agreements on this subject. If any provision is held unenforceable, the remaining provisions remain in effect. This Agreement is executed in English; the Chinese translation below is provided for reference only, and the English version shall prevail in case of any discrepancy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# LangBot 个人贡献者许可协议(v1.0)中文参考译文
|
||||||
|
|
||||||
|
> 本译文仅供参考,如与英文版有任何歧义,以英文版为准。
|
||||||
|
|
||||||
|
感谢您有意为 LangBot(下称"本项目")作出贡献。本项目由北京浪波智能科技有限公司(下称"我方")运营管理。
|
||||||
|
|
||||||
|
本《个人贡献者许可协议》(下称"本协议")旨在记录贡献者授予我方的各项权利。您一经签署本协议(见第 9 条),即接受并同意以下条款与条件,适用于您向本项目提交的现在及未来的全部贡献。除本协议授予我方及我方分发软件之接收者的许可外,您保留对您的贡献的全部权利、所有权和利益。
|
||||||
|
|
||||||
|
## 1. 定义
|
||||||
|
|
||||||
|
"您"指与我方订立本协议的版权所有人,或经版权所有人授权的法律实体。
|
||||||
|
|
||||||
|
"贡献"指您有意提交给我方、用于纳入我方拥有或管理的任何产品或代码仓库(下称"作品")或其文档的任何原创作品,包括对既有作品的修改或增补。就本定义而言,"提交"指以任何电子、口头或书面形式向我方或我方代表发送的通信,包括但不限于在由我方或代表我方管理的电子邮件列表、源代码管理系统和问题跟踪系统中,为讨论和改进作品而进行的通信;但您以显著方式标注或以书面形式声明为"非贡献"(Not a Contribution)的通信除外。
|
||||||
|
|
||||||
|
## 2. 版权许可的授予
|
||||||
|
|
||||||
|
在遵守本协议条款与条件的前提下,您特此授予我方及我方分发软件之接收者一项永久的、全球范围的、非独占的、免费的、免版税的、不可撤销的版权许可,以复制您的贡献、基于其创作衍生作品、公开展示、公开表演、再许可以及分发您的贡献及上述衍生作品。为明确起见,上述许可包括我方有权以任何许可条款(包括但不限于开源许可证以及商业或专有许可证)单独或作为作品的一部分分发您的贡献。
|
||||||
|
|
||||||
|
## 3. 专利许可的授予
|
||||||
|
|
||||||
|
在遵守本协议条款与条件的前提下,您特此授予我方及我方分发软件之接收者一项永久的、全球范围的、非独占的、免费的、免版税的、不可撤销的(本条所述情形除外)专利许可,以制造、委托制造、使用、许诺销售、销售、进口及以其他方式转让作品;该许可仅适用于您可许可的、且因您的贡献本身或您的贡献与其所提交之作品的结合而必然受到侵犯的专利权利要求。如任何实体对您或任何其他实体提起专利诉讼(包括诉讼中的交叉请求或反诉),主张您的贡献或您所贡献的作品构成直接或帮助性专利侵权,则依据本协议就该贡献或作品授予该实体的任何专利许可,自该诉讼提起之日起终止。
|
||||||
|
|
||||||
|
## 4. 权利能力与雇主
|
||||||
|
|
||||||
|
您声明您在法律上有权授予上述许可。如您的雇主对您创作的、包含您的贡献在内的知识产权享有权利,您声明:您已获得该雇主代表其作出贡献的许可,或该雇主已就您向我方的贡献放弃上述权利,或该雇主已与我方另行签署《企业贡献者许可协议》。
|
||||||
|
|
||||||
|
## 5. 原创性声明与披露义务
|
||||||
|
|
||||||
|
您声明您的每项贡献均为您的原创作品(代表第三方提交的情形见第 7 条)。您声明您提交的贡献中已完整披露您本人知悉的、与您的贡献任何部分相关的任何第三方许可或其他限制(包括但不限于相关专利和商标)的全部细节。
|
||||||
|
|
||||||
|
## 6. 无支持义务;免责声明
|
||||||
|
|
||||||
|
您无义务为您的贡献提供支持,除非您自愿提供。您可以免费提供支持、收费提供支持或不提供支持。除非适用法律要求或另有书面约定,您的贡献按"现状"(AS IS)提供,不附带任何明示或默示的保证或条件,包括但不限于关于权属、不侵权、适销性或特定用途适用性的任何保证或条件。
|
||||||
|
|
||||||
|
## 7. 第三方作品
|
||||||
|
|
||||||
|
如您希望提交非您原创的作品,您可以将其与任何贡献分开单独提交给我方,并完整说明其来源以及您本人知悉的任何许可或其他限制(包括但不限于相关专利、商标和许可协议)的全部细节,同时以显著方式将该作品标注为"代表第三方提交:[此处注明第三方名称]"。
|
||||||
|
|
||||||
|
## 8. 通知义务
|
||||||
|
|
||||||
|
如您知悉任何事实或情况将导致上述声明在任何方面不准确,您同意通知我方。
|
||||||
|
|
||||||
|
## 9. 电子签署
|
||||||
|
|
||||||
|
本协议以电子方式接受并签署:您通过您的 GitHub 账号,在本项目代码仓库的拉取请求(pull request)中发表包含我方指定语句(现为 "I have read the CLA Document and I hereby sign the CLA")的评论,即构成您对本协议具有约束力的电子签名。您声明用于签署的 GitHub 账号归您本人所有,且您已达到订立有约束力合同的法定年龄。您的签署覆盖您对我方拥有或管理的全部代码仓库的现在及未来的贡献,直至您以书面形式通知我方就未来贡献退出本协议为止(已授予的许可不可撤销)。
|
||||||
|
|
||||||
|
## 10. 我方承诺
|
||||||
|
|
||||||
|
我方承诺本项目主仓库将持续公开提供作品的开源版本。
|
||||||
|
|
||||||
|
## 11. 其他
|
||||||
|
|
||||||
|
本协议构成您与我方之间就您的贡献达成的完整协议,并取代双方先前就此主题达成的任何协议。如本协议任何条款被认定为不可执行,其余条款仍然有效。本协议以英文签署,中文译文仅供参考,如有歧义以英文版为准。
|
||||||
@@ -14,6 +14,12 @@
|
|||||||
- 在 PR 和 Commit Message 中请使用全英文
|
- 在 PR 和 Commit Message 中请使用全英文
|
||||||
- 对于中文用户,issue 中可以使用中文
|
- 对于中文用户,issue 中可以使用中文
|
||||||
|
|
||||||
|
### 贡献者许可协议(CLA)
|
||||||
|
|
||||||
|
为了保护项目和每一位贡献者,我们要求所有代码贡献者签署[贡献者许可协议(CLA)](./CLA.md)。这是 Apache、Google、Grafana 等主流开源项目的标准做法:您保留自己代码的全部版权,仅授予项目使用、分发您贡献的许可。
|
||||||
|
|
||||||
|
签署只需 10 秒:首次提交 PR 时,机器人会自动评论提示,按提示回复一句话即完成签署,此后对本组织所有仓库永久有效。历史贡献不受影响。
|
||||||
|
|
||||||
<hr/>
|
<hr/>
|
||||||
|
|
||||||
## Guidelines
|
## Guidelines
|
||||||
@@ -29,3 +35,9 @@
|
|||||||
|
|
||||||
- Use English in PRs and Commit Messages
|
- Use English in PRs and Commit Messages
|
||||||
- For English users, you can use English in issues
|
- For English users, you can use English in issues
|
||||||
|
|
||||||
|
### Contributor License Agreement (CLA)
|
||||||
|
|
||||||
|
To protect the project and every contributor, we require all code contributors to sign our [Contributor License Agreement](./CLA.md). This is standard practice in major open source projects such as Apache, Google, and Grafana: you keep full copyright of your code — the CLA only grants us a license to use and distribute your contribution.
|
||||||
|
|
||||||
|
Signing takes 10 seconds: when you open your first PR, a bot will guide you to reply with a single comment. One signature covers all repositories in this organization, permanently. Past contributions are not affected.
|
||||||
|
|||||||
48
Dockerfile
48
Dockerfile
@@ -4,7 +4,26 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY web ./web
|
COPY web ./web
|
||||||
|
|
||||||
RUN cd web && npm install && npm run build
|
RUN cd web && npm install && npx vite build
|
||||||
|
|
||||||
|
# Build nsjail from source so the image ships a self-contained sandbox backend
|
||||||
|
# that needs no host Docker socket. Pinned to a release tag for reproducibility.
|
||||||
|
# Multi-stage keeps the compile toolchain (bison/flex/protobuf-dev/libnl-dev)
|
||||||
|
# out of the final image; only the nsjail binary and its small runtime libs
|
||||||
|
# (libprotobuf, libnl-route-3) are carried over.
|
||||||
|
FROM python:3.12.7-slim AS nsjail-build
|
||||||
|
|
||||||
|
ARG NSJAIL_VERSION=3.6
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates git build-essential \
|
||||||
|
autoconf bison flex libtool pkg-config \
|
||||||
|
protobuf-compiler libprotobuf-dev libnl-route-3-dev \
|
||||||
|
&& git clone --depth 1 --branch "${NSJAIL_VERSION}" https://github.com/google/nsjail.git /nsjail \
|
||||||
|
&& make -C /nsjail \
|
||||||
|
&& install -m 0755 /nsjail/nsjail /usr/local/bin/nsjail \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
FROM python:3.12.7-slim
|
FROM python:3.12.7-slim
|
||||||
|
|
||||||
@@ -12,12 +31,31 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
COPY --from=node /app/web/out ./web/out
|
COPY --from=node /app/web/dist ./web/dist
|
||||||
|
|
||||||
RUN apt update \
|
# nsjail binary built in the dedicated stage above. Self-contained sandbox
|
||||||
&& apt install gcc -y \
|
# backend; lets the Box runtime isolate code without a host Docker socket.
|
||||||
|
COPY --from=nsjail-build /usr/local/bin/nsjail /usr/local/bin/nsjail
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends gcc ca-certificates curl gnupg \
|
||||||
|
# nsjail runtime libraries (the build toolchain stays in the nsjail-build
|
||||||
|
# stage; only these shared libs are needed to execute the binary).
|
||||||
|
&& apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 \
|
||||||
|
# Install the Docker CLI (client only) so the optional langbot_box
|
||||||
|
# service can drive the mounted host Docker socket and create sandbox
|
||||||
|
# containers. The same image powers langbot / plugin_runtime / box; only
|
||||||
|
# box uses the client. Arch-aware via dpkg so multi-arch builds work.
|
||||||
|
&& install -m 0755 -d /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
|
||||||
|
&& chmod a+r /etc/apt/keyrings/docker.asc \
|
||||||
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" > /etc/apt/sources.list.d/docker.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends docker-ce-cli \
|
||||||
&& python -m pip install --no-cache-dir uv \
|
&& python -m pip install --no-cache-dir uv \
|
||||||
&& uv sync \
|
&& uv sync \
|
||||||
|
&& apt-get purge -y --auto-remove curl gnupg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& touch /.dockerenv
|
&& touch /.dockerenv
|
||||||
|
|
||||||
CMD [ "uv", "run", "main.py" ]
|
CMD [ "uv", "run", "--no-sync", "main.py" ]
|
||||||
36
Makefile
Normal file
36
Makefile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# LangBot Makefile
|
||||||
|
# Quick developer commands
|
||||||
|
|
||||||
|
.PHONY: test test-quick test-integration-fast test-coverage test-all-local lint
|
||||||
|
|
||||||
|
# Run all tests (full suite with coverage)
|
||||||
|
test:
|
||||||
|
bash run_tests.sh
|
||||||
|
|
||||||
|
# Quick self-test for developers (lint + unit + smoke, no real credentials needed)
|
||||||
|
test-quick:
|
||||||
|
bash scripts/test-quick.sh
|
||||||
|
|
||||||
|
# Fast integration tests (SQLite/API/Pipeline, no external services)
|
||||||
|
test-integration-fast:
|
||||||
|
bash scripts/test-integration-fast.sh
|
||||||
|
|
||||||
|
# Coverage gate (all tests, enforces minimum threshold)
|
||||||
|
test-coverage:
|
||||||
|
bash scripts/test-coverage.sh
|
||||||
|
|
||||||
|
# Full local quality gate (quick + integration + coverage)
|
||||||
|
test-all-local:
|
||||||
|
bash scripts/test-quick.sh
|
||||||
|
bash scripts/test-integration-fast.sh
|
||||||
|
bash scripts/test-coverage.sh
|
||||||
|
|
||||||
|
# Run linting only
|
||||||
|
lint:
|
||||||
|
ruff check src/langbot/ tests/
|
||||||
|
ruff format --check src/langbot/ tests/
|
||||||
|
|
||||||
|
# Fix linting issues
|
||||||
|
lint-fix:
|
||||||
|
ruff check --fix src/langbot/ tests/
|
||||||
|
ruff format src/langbot/ tests/
|
||||||
235
README.md
235
README.md
@@ -1,56 +1,71 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://langbot.app">
|
<a href="https://langbot.app">
|
||||||
<img src="https://docs.langbot.app/social_zh.png" alt="LangBot"/>
|
<img width="130" src="res/logo-blue.png" alt="LangBot"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
[English](README_EN.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / (PR for your language)
|
<h3>Production-grade platform for building agentic IM bots.</h3>
|
||||||
|
<h4>Quickly build, debug, and ship AI bots to Slack, Discord, Telegram, WeChat, and more.</h4>
|
||||||
|
|
||||||
|
English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||||
|
|
||||||
[](https://discord.gg/wdNEHETs87)
|
[](https://discord.gg/wdNEHETs87)
|
||||||
[](https://qm.qq.com/q/JLi38whHum)
|
|
||||||
[](https://deepwiki.com/langbot-app/LangBot)
|
[](https://deepwiki.com/langbot-app/LangBot)
|
||||||
[](https://github.com/langbot-app/LangBot/releases/latest)
|
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||||
[](https://gitcode.com/RockChinQ/LangBot)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">项目主页</a> |
|
|
||||||
<a href="https://docs.langbot.app/zh/insight/guide.html">部署文档</a> |
|
|
||||||
<a href="https://docs.langbot.app/zh/plugin/plugin-intro.html">插件介绍</a> |
|
|
||||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">提交插件</a>
|
|
||||||
|
|
||||||
|
<a href="https://langbot.app">Website</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/features">Features</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/guide">Docs</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
|
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||||
|
<a href="https://space.langbot.app">Plugin Market</a> |
|
||||||
|
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
LangBot 是一个开源的大语言模型原生即时通信机器人开发平台,旨在提供开箱即用的 IM 机器人开发体验,具有 Agent、RAG、MCP 等多种 LLM 应用功能,适配全球主流即时通信平台,并提供丰富的 API 接口,支持自定义开发。
|
---
|
||||||
|
|
||||||
## 📦 开始使用
|
## What is LangBot?
|
||||||
|
|
||||||
#### 快速体验(推荐)
|
LangBot is an **open-source, production-grade platform** for building AI-powered instant messaging bots. It connects Large Language Models (LLMs) to any chat platform, enabling you to create intelligent agents that can converse, execute tasks, and integrate with your existing workflows.
|
||||||
|
|
||||||
使用 `uvx` 一键启动(无需安装):
|
### Key Capabilities
|
||||||
|
|
||||||
|
- **AI Conversations & Agents** — Multi-turn dialogues, tool calling, multi-modal support, streaming output. Built-in RAG (knowledge base) with deep integration to [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||||
|
- **Universal IM Platform Support** — One codebase for Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||||
|
- **Production-Ready** — Access control, rate limiting, sensitive word filtering, comprehensive monitoring, and exception handling. Trusted by enterprises.
|
||||||
|
- **Plugin Ecosystem** — Hundreds of plugins, event-driven architecture, component extensions, and [MCP protocol](https://modelcontextprotocol.io/) support.
|
||||||
|
- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.
|
||||||
|
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
|
||||||
|
|
||||||
|
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### ☁️ LangBot Cloud (Recommended)
|
||||||
|
|
||||||
|
**[LangBot Cloud](https://space.langbot.app/cloud)** — Zero deployment, ready to use.
|
||||||
|
|
||||||
|
### One-Line Launch
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx langbot
|
uvx langbot
|
||||||
```
|
```
|
||||||
|
|
||||||
或使用 `pip` 安装后运行:
|
> Requires [uv](https://docs.astral.sh/uv/getting-started/installation/). Visit http://localhost:5300 — done.
|
||||||
|
|
||||||
```bash
|
### Docker Compose
|
||||||
pip install langbot
|
|
||||||
langbot
|
|
||||||
```
|
|
||||||
|
|
||||||
访问 http://localhost:5300 即可开始使用。
|
|
||||||
|
|
||||||
详细文档[PyPI 安装](docs/PYPI_INSTALLATION.md)。
|
|
||||||
|
|
||||||
#### Docker Compose 部署
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/langbot-app/LangBot
|
git clone https://github.com/langbot-app/LangBot
|
||||||
@@ -58,122 +73,106 @@ cd LangBot/docker
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
访问 http://localhost:5300 即可开始使用。
|
### One-Click Cloud Deploy
|
||||||
|
|
||||||
详细文档[Docker 部署](https://docs.langbot.app/zh/deploy/langbot/docker.html)。
|
|
||||||
|
|
||||||
#### 宝塔面板部署
|
|
||||||
|
|
||||||
已上架宝塔面板,若您已安装宝塔面板,可以根据[文档](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html)使用。
|
|
||||||
|
|
||||||
#### Zeabur 云部署
|
|
||||||
|
|
||||||
社区贡献的 Zeabur 模板。
|
|
||||||
|
|
||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
|
||||||
|
|
||||||
#### Railway 云部署
|
|
||||||
|
|
||||||
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
#### 手动部署
|
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
直接使用发行版运行,查看文档[手动部署](https://docs.langbot.app/zh/deploy/langbot/manual.html)。
|
---
|
||||||
|
|
||||||
#### Kubernetes 部署
|
## Supported Platforms
|
||||||
|
|
||||||
参考 [Kubernetes 部署](./docker/README_K8S.md) 文档。
|
| Platform | Status | Notes |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| Discord | ✅ | Official |
|
||||||
|
| Telegram | ✅ | Official |
|
||||||
|
| Slack | ✅ | Official |
|
||||||
|
| LINE | ✅ | Official |
|
||||||
|
| QQ | ✅ | Personal & Official API (Channel, DM, Group) |
|
||||||
|
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
|
||||||
|
| WeChat | ✅ | Personal & Official Account |
|
||||||
|
| Lark | ✅ | Official |
|
||||||
|
| DingTalk | ✅ | Official |
|
||||||
|
| KOOK | ✅ | Official |
|
||||||
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Supports multiple bridged platforms such as Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, and more |
|
||||||
|
|
||||||
## 😎 保持更新
|
---
|
||||||
|
|
||||||
点击仓库右上角 Star 和 Watch 按钮,获取最新动态。
|
## Supported LLMs & Integrations
|
||||||
|
|
||||||

|
| Provider | Type | Status |
|
||||||
|
| ----------------------------------------------------------------------------------------------------------------- | ------------ | ------ |
|
||||||
|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
||||||
|
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
||||||
|
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
||||||
|
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
||||||
|
| [xAI](https://x.ai/) | LLM | ✅ |
|
||||||
|
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
||||||
|
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
||||||
|
| [Ollama](https://ollama.com/) | Local LLM | ✅ |
|
||||||
|
| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |
|
||||||
|
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
||||||
|
| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |
|
||||||
|
| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |
|
||||||
|
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ |
|
||||||
|
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ |
|
||||||
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ |
|
||||||
|
| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |
|
||||||
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ |
|
||||||
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
|
||||||
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
|
||||||
|
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
||||||
|
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
||||||
|
|
||||||
## ✨ 特性
|
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
- 💬 大模型对话、Agent:支持多种大模型,适配群聊和私聊;具有多轮对话、工具调用、多模态、流式输出能力,自带 RAG(知识库)实现,并深度适配 [Dify](https://dify.ai)。
|
---
|
||||||
- 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram 等平台。
|
|
||||||
- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。支持多流水线配置,不同机器人用于不同应用场景。
|
|
||||||
- 🧩 插件扩展、活跃社区:支持事件驱动、组件扩展等插件机制;适配 Anthropic [MCP 协议](https://modelcontextprotocol.io/);目前已有数百个插件。
|
|
||||||
- 😻 Web 管理面板:支持通过浏览器管理 LangBot 实例,不再需要手动编写配置文件。
|
|
||||||
|
|
||||||
详细规格特性请访问[文档](https://docs.langbot.app/zh/insight/features.html)。
|
## Why LangBot?
|
||||||
|
|
||||||
或访问 demo 环境:https://demo.langbot.dev/
|
| Use Case | How LangBot Helps |
|
||||||
- 登录信息:邮箱:`demo@langbot.app` 密码:`langbot123456`
|
| --------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
- 注意:仅展示 WebUI 效果,公开环境,请不要在其中填入您的任何敏感信息。
|
| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base |
|
||||||
|
| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes |
|
||||||
|
| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction |
|
||||||
|
| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard |
|
||||||
|
|
||||||
### 消息平台
|
---
|
||||||
|
|
||||||
| 平台 | 状态 | 备注 |
|
## Live Demo
|
||||||
| --- | --- | --- |
|
|
||||||
| QQ 个人号 | ✅ | QQ 个人号私聊、群聊 |
|
|
||||||
| QQ 官方机器人 | ✅ | QQ 官方机器人,支持频道、私聊、群聊 |
|
|
||||||
| 企业微信 | ✅ | |
|
|
||||||
| 企微对外客服 | ✅ | |
|
|
||||||
| 企微智能机器人 | ✅ | |
|
|
||||||
| 个人微信 | ✅ | |
|
|
||||||
| 微信公众号 | ✅ | |
|
|
||||||
| 飞书 | ✅ | |
|
|
||||||
| 钉钉 | ✅ | |
|
|
||||||
| Discord | ✅ | |
|
|
||||||
| Telegram | ✅ | |
|
|
||||||
| Slack | ✅ | |
|
|
||||||
| LINE | ✅ | |
|
|
||||||
|
|
||||||
### 大模型能力
|
**Try it now:** https://demo.langbot.dev/
|
||||||
|
|
||||||
| 模型 | 状态 | 备注 |
|
- Email: `demo@langbot.app`
|
||||||
| --- | --- | --- |
|
- Password: `langbot123456`
|
||||||
| [OpenAI](https://platform.openai.com/) | ✅ | 可接入任何 OpenAI 接口格式模型 |
|
|
||||||
| [DeepSeek](https://www.deepseek.com/) | ✅ | |
|
|
||||||
| [Moonshot](https://www.moonshot.cn/) | ✅ | |
|
|
||||||
| [Anthropic](https://www.anthropic.com/) | ✅ | |
|
|
||||||
| [xAI](https://x.ai/) | ✅ | |
|
|
||||||
| [智谱AI](https://open.bigmodel.cn/) | ✅ | |
|
|
||||||
| [胜算云](https://www.shengsuanyun.com/?from=CH_KYIPP758) | ✅ | 全球大模型都可调用(友情推荐) |
|
|
||||||
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | 大模型和 GPU 资源平台 |
|
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型和 GPU 资源平台 |
|
|
||||||
| [接口 AI](https://jiekou.ai/) | ✅ | 大模型聚合平台,专注全球大模型接入 |
|
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | ✅ | 大模型聚合平台 |
|
|
||||||
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
|
|
||||||
| [Dify](https://dify.ai) | ✅ | LLMOps 平台 |
|
|
||||||
| [Ollama](https://ollama.com/) | ✅ | 本地大模型运行平台 |
|
|
||||||
| [LMStudio](https://lmstudio.ai/) | ✅ | 本地大模型运行平台 |
|
|
||||||
| [GiteeAI](https://ai.gitee.com/) | ✅ | 大模型接口聚合平台 |
|
|
||||||
| [SiliconFlow](https://siliconflow.cn/) | ✅ | 大模型聚合平台 |
|
|
||||||
| [小马算力](https://www.tokenpony.cn/453z1) | ✅ | 大模型聚合平台 |
|
|
||||||
| [阿里云百炼](https://bailian.console.aliyun.com/) | ✅ | 大模型聚合平台, LLMOps 平台 |
|
|
||||||
| [火山方舟](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | 大模型聚合平台, LLMOps 平台 |
|
|
||||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | 大模型聚合平台 |
|
|
||||||
| [MCP](https://modelcontextprotocol.io/) | ✅ | 支持通过 MCP 协议获取工具 |
|
|
||||||
| [百宝箱Tbox](https://www.tbox.cn/open) | ✅ | 蚂蚁百宝箱智能体平台,每月免费10亿大模型Token |
|
|
||||||
|
|
||||||
### TTS
|
_Note: Public demo environment. Do not enter sensitive information._
|
||||||
|
|
||||||
| 平台/模型 | 备注 |
|
---
|
||||||
| --- | --- |
|
|
||||||
| [FishAudio](https://fish.audio/zh-CN/discovery/) | [插件](https://github.com/the-lazy-me/NewChatVoice) |
|
|
||||||
| [海豚 AI](https://www.ttson.cn/?source=thelazy) | [插件](https://github.com/the-lazy-me/NewChatVoice) |
|
|
||||||
| [AzureTTS](https://portal.azure.com/) | [插件](https://github.com/Ingnaryk/LangBot_AzureTTS) |
|
|
||||||
|
|
||||||
### 文生图
|
## Community
|
||||||
|
|
||||||
| 平台/模型 | 备注 |
|
[](https://discord.gg/wdNEHETs87)
|
||||||
| --- | --- |
|
|
||||||
| 阿里云百炼 | [插件](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin)
|
|
||||||
|
|
||||||
## 😘 社区贡献
|
- [Discord Community](https://discord.gg/wdNEHETs87)
|
||||||
|
|
||||||
感谢以下[代码贡献者](https://github.com/langbot-app/LangBot/graphs/contributors)和社区里其他成员对 LangBot 的贡献:
|
---
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thanks to all [contributors](https://github.com/langbot-app/LangBot/graphs/contributors) who have helped make LangBot better:
|
||||||
|
|
||||||
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!--
|
|
||||||
## For Code Agents
|
|
||||||
|
|
||||||
If you're a code agent based on LLM, you should read [AGENTS.md](AGENTS.md) to understand the project structure and development process before you start any development.
|
|
||||||
-->
|
|
||||||
|
|||||||
201
README_CN.md
Normal file
201
README_CN.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://langbot.app">
|
||||||
|
<img width="130" src="res/logo-blue.png" alt="LangBot"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
|
<h3>生产级 AI 即时通信机器人开发平台。</h3>
|
||||||
|
<h4>快速构建、调试和部署 AI 机器人到微信、QQ、飞书、Slack、Discord、Telegram 等平台。</h4>
|
||||||
|
|
||||||
|
[English](README.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||||
|
|
||||||
|
[](https://discord.gg/wdNEHETs87)
|
||||||
|
[](https://qm.qq.com/q/IrlV8QFacU)
|
||||||
|
[](https://deepwiki.com/langbot-app/LangBot)
|
||||||
|
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||||
|
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||||
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
|
<a href="https://langbot.app">官网</a> |
|
||||||
|
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||||
|
<a href="https://link.langbot.app/zh/docs/guide">文档</a> |
|
||||||
|
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||||
|
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||||
|
<a href="https://space.langbot.app">扩展市场</a> |
|
||||||
|
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型(LLM)连接到各种聊天平台,帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。
|
||||||
|
|
||||||
|
### 核心能力
|
||||||
|
|
||||||
|
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。
|
||||||
|
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||||
|
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
||||||
|
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
||||||
|
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
||||||
|
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
||||||
|
|
||||||
|
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
|
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://blog.langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://blog.langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://blog.langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://blog.langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### ☁️ LangBot Cloud(推荐)
|
||||||
|
|
||||||
|
**[LangBot Cloud](https://space.langbot.app/cloud)** — 免部署,开箱即用。
|
||||||
|
|
||||||
|
### 一键启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx langbot
|
||||||
|
```
|
||||||
|
|
||||||
|
> 需要安装 [uv](https://docs.astral.sh/uv/getting-started/installation/)。访问 http://localhost:5300 即可使用。
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/langbot-app/LangBot
|
||||||
|
cd LangBot/docker
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 一键云部署
|
||||||
|
|
||||||
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
|
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 支持的平台
|
||||||
|
|
||||||
|
| 平台 | 状态 | 备注 |
|
||||||
|
|------|------|------|
|
||||||
|
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
|
||||||
|
| 微信 | ✅ | 个人微信、微信公众号 |
|
||||||
|
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
|
||||||
|
| 飞书 | ✅ | 官方 |
|
||||||
|
| 钉钉 | ✅ | 官方 |
|
||||||
|
| Satori | ✅ | |
|
||||||
|
| Discord | ✅ | 官方 |
|
||||||
|
| Telegram | ✅ | 官方 |
|
||||||
|
| Slack | ✅ | 官方 |
|
||||||
|
| LINE | ✅ | 官方 |
|
||||||
|
| KOOK | ✅ | 官方 |
|
||||||
|
| Email | ✅ | 只 Matrix、Satori |
|
||||||
|
| Matrix | ✅ | 支持多种桥接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 支持的大模型与集成
|
||||||
|
|
||||||
|
| 提供商 | 类型 | 状态 |
|
||||||
|
|--------|------|------|
|
||||||
|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
||||||
|
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
||||||
|
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
||||||
|
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
||||||
|
| [xAI](https://x.ai/) | LLM | ✅ |
|
||||||
|
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
||||||
|
| [智谱AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
||||||
|
| [Ollama](https://ollama.com/) | 本地 LLM | ✅ |
|
||||||
|
| [LM Studio](https://lmstudio.ai/) | 本地 LLM | ✅ |
|
||||||
|
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
||||||
|
| [MCP](https://modelcontextprotocol.io/) | 协议 | ✅ |
|
||||||
|
| [SiliconFlow](https://siliconflow.cn/) | 聚合平台 | ✅ |
|
||||||
|
| [阿里云百炼](https://bailian.console.aliyun.com/) | 聚合平台 | ✅ |
|
||||||
|
| [火山方舟](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | 聚合平台 | ✅ |
|
||||||
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | 聚合平台 | ✅ |
|
||||||
|
| [GiteeAI](https://ai.gitee.com/) | 聚合平台 | ✅ |
|
||||||
|
| [胜算云](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 平台 | ✅ |
|
||||||
|
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |
|
||||||
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
||||||
|
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
||||||
|
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
||||||
|
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
||||||
|
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||||
|
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|
||||||
|
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
|
### TTS(语音合成)
|
||||||
|
|
||||||
|
| 平台/模型 | 备注 |
|
||||||
|
|-----------|------|
|
||||||
|
| [FishAudio](https://fish.audio/zh-CN/discovery/) | [插件](https://github.com/the-lazy-me/NewChatVoice) |
|
||||||
|
| [海豚 AI](https://www.ttson.cn/?source=thelazy) | [插件](https://github.com/the-lazy-me/NewChatVoice) |
|
||||||
|
| [AzureTTS](https://portal.azure.com/) | [插件](https://github.com/Ingnaryk/LangBot_AzureTTS) |
|
||||||
|
|
||||||
|
### 文生图
|
||||||
|
|
||||||
|
| 平台/模型 | 备注 |
|
||||||
|
|-----------|------|
|
||||||
|
| 阿里云百炼 | [插件](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么选择 LangBot?
|
||||||
|
|
||||||
|
| 使用场景 | LangBot 如何帮助 |
|
||||||
|
|----------|------------------|
|
||||||
|
| **客户服务** | 将 AI Agent 部署到微信/企微/钉钉/飞书,基于知识库自动回答用户问题 |
|
||||||
|
| **内部工具** | 将 n8n/Dify 工作流接入企微/钉钉,实现业务流程自动化 |
|
||||||
|
| **社群运营** | 在 QQ/Discord 群中使用 AI 驱动的内容审核与智能互动 |
|
||||||
|
| **多平台触达** | 一个机器人,覆盖所有平台。通过统一面板集中管理 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 在线演示
|
||||||
|
|
||||||
|
**立即体验:** https://demo.langbot.dev/
|
||||||
|
- 邮箱:`demo@langbot.app`
|
||||||
|
- 密码:`langbot123456`
|
||||||
|
|
||||||
|
*注意:公开演示环境,请不要在其中填入任何敏感信息。*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 社区
|
||||||
|
|
||||||
|
[](https://discord.gg/wdNEHETs87)
|
||||||
|
[](https://qm.qq.com/q/DxZZcNxM1W)
|
||||||
|
|
||||||
|
- [Discord 社区](https://discord.gg/wdNEHETs87)
|
||||||
|
- [QQ 社区群](https://qm.qq.com/q/DxZZcNxM1W)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Star 趋势
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 贡献者
|
||||||
|
|
||||||
|
感谢所有[贡献者](https://github.com/langbot-app/LangBot/graphs/contributors)对 LangBot 的帮助:
|
||||||
|
|
||||||
|
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
## For Code Agents
|
||||||
|
|
||||||
|
If you're a code agent based on LLM, you should read [AGENTS.md](AGENTS.md) to understand the project structure and development process before you start any development.
|
||||||
|
-->
|
||||||
150
README_EN.md
150
README_EN.md
@@ -1,150 +0,0 @@
|
|||||||
<p align="center">
|
|
||||||
<a href="https://langbot.app">
|
|
||||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
English / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / (PR for your language)
|
|
||||||
|
|
||||||
[](https://discord.gg/wdNEHETs87)
|
|
||||||
[](https://deepwiki.com/langbot-app/LangBot)
|
|
||||||
[](https://github.com/langbot-app/LangBot/releases/latest)
|
|
||||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
|
||||||
|
|
||||||
<a href="https://langbot.app">Home</a> |
|
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Deployment</a> |
|
|
||||||
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a> |
|
|
||||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Submit Plugin</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</p>
|
|
||||||
|
|
||||||
LangBot is an open-source LLM native instant messaging robot development platform, aiming to provide out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, adapting to global instant messaging platforms, and providing rich API interfaces, supporting custom development.
|
|
||||||
|
|
||||||
## 📦 Getting Started
|
|
||||||
|
|
||||||
#### Quick Start (Recommended)
|
|
||||||
|
|
||||||
Use `uvx` to start with one command (no installation required):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uvx langbot
|
|
||||||
```
|
|
||||||
|
|
||||||
Or install with `pip` and run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install langbot
|
|
||||||
langbot
|
|
||||||
```
|
|
||||||
|
|
||||||
Visit http://localhost:5300 to start using it.
|
|
||||||
|
|
||||||
Detailed documentation [PyPI Installation](docs/PYPI_INSTALLATION.md).
|
|
||||||
|
|
||||||
#### Docker Compose Deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/langbot-app/LangBot
|
|
||||||
cd LangBot/docker
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Visit http://localhost:5300 to start using it.
|
|
||||||
|
|
||||||
Detailed documentation [Docker Deployment](https://docs.langbot.app/en/deploy/langbot/docker.html).
|
|
||||||
|
|
||||||
#### One-click Deployment on BTPanel
|
|
||||||
|
|
||||||
LangBot has been listed on the BTPanel, if you have installed the BTPanel, you can use the [document](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) to use it.
|
|
||||||
|
|
||||||
#### Zeabur Cloud Deployment
|
|
||||||
|
|
||||||
Community contributed Zeabur template.
|
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
|
||||||
|
|
||||||
#### Railway Cloud Deployment
|
|
||||||
|
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
|
||||||
|
|
||||||
#### Other Deployment Methods
|
|
||||||
|
|
||||||
Directly use the released version to run, see the [Manual Deployment](https://docs.langbot.app/en/deploy/langbot/manual.html) documentation.
|
|
||||||
|
|
||||||
#### Kubernetes Deployment
|
|
||||||
|
|
||||||
Refer to the [Kubernetes Deployment](./docker/README_K8S.md) documentation.
|
|
||||||
|
|
||||||
## 😎 Stay Ahead
|
|
||||||
|
|
||||||
Click the Star and Watch button in the upper right corner of the repository to get the latest updates.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## ✨ Features
|
|
||||||
|
|
||||||
- 💬 Chat with LLM / Agent: Supports multiple LLMs, adapt to group chats and private chats; Supports multi-round conversations, tool calls, multi-modal, and streaming output capabilities. Built-in RAG (knowledge base) implementation, and deeply integrates with [Dify](https://dify.ai).
|
|
||||||
- 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, etc.
|
|
||||||
- 🛠️ High Stability, Feature-rich: Native access control, rate limiting, sensitive word filtering, etc. mechanisms; Easy to use, supports multiple deployment methods. Supports multiple pipeline configurations, different bots can be used for different scenarios.
|
|
||||||
- 🧩 Plugin Extension, Active Community: Support event-driven, component extension, etc. plugin mechanisms; Integrate Anthropic [MCP protocol](https://modelcontextprotocol.io/); Currently has hundreds of plugins.
|
|
||||||
- 😻 Web UI: Support management LangBot instance through the browser. No need to manually write configuration files.
|
|
||||||
|
|
||||||
For more detailed specifications, please refer to the [documentation](https://docs.langbot.app/en/insight/features.html).
|
|
||||||
|
|
||||||
Or visit the demo environment: https://demo.langbot.dev/
|
|
||||||
- Login information: Email: `demo@langbot.app` Password: `langbot123456`
|
|
||||||
- Note: For WebUI demo only, please do not fill in any sensitive information in the public environment.
|
|
||||||
|
|
||||||
### Message Platform
|
|
||||||
|
|
||||||
| Platform | Status | Remarks |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Discord | ✅ | |
|
|
||||||
| Telegram | ✅ | |
|
|
||||||
| Slack | ✅ | |
|
|
||||||
| LINE | ✅ | |
|
|
||||||
| Personal QQ | ✅ | |
|
|
||||||
| QQ Official API | ✅ | |
|
|
||||||
| WeCom | ✅ | |
|
|
||||||
| WeComCS | ✅ | |
|
|
||||||
| WeCom AI Bot | ✅ | |
|
|
||||||
| Personal WeChat | ✅ | |
|
|
||||||
| Lark | ✅ | |
|
|
||||||
| DingTalk | ✅ | |
|
|
||||||
|
|
||||||
### LLMs
|
|
||||||
|
|
||||||
| LLM | Status | Remarks |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| [OpenAI](https://platform.openai.com/) | ✅ | Available for any OpenAI interface format model |
|
|
||||||
| [DeepSeek](https://www.deepseek.com/) | ✅ | |
|
|
||||||
| [Moonshot](https://www.moonshot.cn/) | ✅ | |
|
|
||||||
| [Anthropic](https://www.anthropic.com/) | ✅ | |
|
|
||||||
| [xAI](https://x.ai/) | ✅ | |
|
|
||||||
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | |
|
|
||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | LLM and GPU resource platform |
|
|
||||||
| [Dify](https://dify.ai) | ✅ | LLMOps platform |
|
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | LLM and GPU resource platform |
|
|
||||||
| [接口 AI](https://jiekou.ai/) | ✅ | LLM aggregation platform, dedicated to global LLMs |
|
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | ✅ | LLM and GPU resource platform |
|
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | ✅ | LLM gateway(MaaS) |
|
|
||||||
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
|
|
||||||
| [Ollama](https://ollama.com/) | ✅ | Local LLM running platform |
|
|
||||||
| [LMStudio](https://lmstudio.ai/) | ✅ | Local LLM running platform |
|
|
||||||
| [GiteeAI](https://ai.gitee.com/) | ✅ | LLM interface gateway(MaaS) |
|
|
||||||
| [SiliconFlow](https://siliconflow.cn/) | ✅ | LLM gateway(MaaS) |
|
|
||||||
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | ✅ | LLM gateway(MaaS), LLMOps platform |
|
|
||||||
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | LLM gateway(MaaS), LLMOps platform |
|
|
||||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | LLM gateway(MaaS) |
|
|
||||||
| [MCP](https://modelcontextprotocol.io/) | ✅ | Support tool access through MCP protocol |
|
|
||||||
|
|
||||||
## 🤝 Community Contribution
|
|
||||||
|
|
||||||
Thank you for the following [code contributors](https://github.com/langbot-app/LangBot/graphs/contributors) and other members in the community for their contributions to LangBot:
|
|
||||||
|
|
||||||
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
|
||||||
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
|
||||||
</a>
|
|
||||||
176
README_ES.md
Normal file
176
README_ES.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://langbot.app">
|
||||||
|
<img width="130" src="res/logo-blue.png" alt="LangBot"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
|
<h3>Plataforma de grado de producción para construir bots de mensajería instantánea con agentes de IA.</h3>
|
||||||
|
<h4>Construya, depure y despliegue bots de IA rápidamente en Slack, Discord, Telegram, WeChat y más.</h4>
|
||||||
|
|
||||||
|
[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / Español / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||||
|
|
||||||
|
[](https://discord.gg/wdNEHETs87)
|
||||||
|
[](https://deepwiki.com/langbot-app/LangBot)
|
||||||
|
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||||
|
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||||
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
|
<a href="https://langbot.app">Inicio</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/features">Características</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/guide">Documentación</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
|
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
||||||
|
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ¿Qué es LangBot?
|
||||||
|
|
||||||
|
LangBot es una **plataforma de código abierto y grado de producción** para construir bots de mensajería instantánea impulsados por IA. Conecta modelos de lenguaje de gran escala (LLMs) con cualquier plataforma de chat, permitiéndole crear agentes inteligentes que pueden conversar, ejecutar tareas e integrarse con sus flujos de trabajo existentes.
|
||||||
|
|
||||||
|
### Capacidades Clave
|
||||||
|
|
||||||
|
- **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com).
|
||||||
|
- **Soporte Universal de Plataformas de MI** — Un solo código base para Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||||
|
- **Listo para Producción** — Control de acceso, limitación de velocidad, filtrado de palabras sensibles, monitoreo completo y manejo de excepciones. De confianza para empresas.
|
||||||
|
- **Ecosistema de Plugins** — Cientos de plugins, arquitectura basada en eventos, extensiones de componentes y soporte del [protocolo MCP](https://modelcontextprotocol.io/).
|
||||||
|
- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.
|
||||||
|
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
|
||||||
|
|
||||||
|
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inicio Rápido
|
||||||
|
|
||||||
|
### ☁️ LangBot Cloud (Recomendado)
|
||||||
|
|
||||||
|
**[LangBot Cloud](https://space.langbot.app/cloud)** — Sin despliegue, listo para usar.
|
||||||
|
|
||||||
|
### Lanzamiento en una línea
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx langbot
|
||||||
|
```
|
||||||
|
|
||||||
|
> Requiere [uv](https://docs.astral.sh/uv/getting-started/installation/). Visite http://localhost:5300 — listo.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/langbot-app/LangBot
|
||||||
|
cd LangBot/docker
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Despliegue en la Nube con un Clic
|
||||||
|
|
||||||
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
|
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plataformas Soportadas
|
||||||
|
|
||||||
|
| Plataforma | Estado | Notas |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| Discord | ✅ | Oficial |
|
||||||
|
| Telegram | ✅ | Oficial |
|
||||||
|
| Slack | ✅ | Oficial |
|
||||||
|
| LINE | ✅ | Oficial |
|
||||||
|
| QQ | ✅ | Personal y API Oficial (Canal, DM, Grupo) |
|
||||||
|
| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |
|
||||||
|
| WeChat | ✅ | Personal y Cuenta Oficial |
|
||||||
|
| Lark | ✅ | Oficial |
|
||||||
|
| DingTalk | ✅ | Oficial |
|
||||||
|
| KOOK | ✅ | Oficial |
|
||||||
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Admite varias plataformas puenteadas como Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip y más |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LLMs e Integraciones Soportadas
|
||||||
|
|
||||||
|
| Proveedor | Tipo | Estado |
|
||||||
|
|----------|------|--------|
|
||||||
|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
||||||
|
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
||||||
|
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
||||||
|
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
||||||
|
| [xAI](https://x.ai/) | LLM | ✅ |
|
||||||
|
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
||||||
|
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
||||||
|
| [Ollama](https://ollama.com/) | LLM Local | ✅ |
|
||||||
|
| [LM Studio](https://lmstudio.ai/) | LLM Local | ✅ |
|
||||||
|
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
||||||
|
| [MCP](https://modelcontextprotocol.io/) | Protocolo | ✅ |
|
||||||
|
| [SiliconFlow](https://siliconflow.cn/) | Pasarela | ✅ |
|
||||||
|
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Pasarela | ✅ |
|
||||||
|
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Pasarela | ✅ |
|
||||||
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Pasarela | ✅ |
|
||||||
|
| [GiteeAI](https://ai.gitee.com/) | Pasarela | ✅ |
|
||||||
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plataforma GPU | ✅ |
|
||||||
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plataforma GPU | ✅ |
|
||||||
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
|
||||||
|
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
||||||
|
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
||||||
|
|
||||||
|
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ¿Por qué LangBot?
|
||||||
|
|
||||||
|
| Caso de Uso | Cómo Ayuda LangBot |
|
||||||
|
|----------|-------------------|
|
||||||
|
| **Atención al cliente** | Despliegue agentes de IA en Slack/Discord/Telegram que respondan preguntas usando su base de conocimientos |
|
||||||
|
| **Herramientas internas** | Conecte flujos de trabajo de n8n/Dify a WeCom/DingTalk para procesos empresariales automatizados |
|
||||||
|
| **Gestión de comunidades** | Modere grupos de QQ/Discord con filtrado de contenido e interacción impulsados por IA |
|
||||||
|
| **Presencia multiplataforma** | Un solo bot, todas las plataformas. Gestione desde un único panel de control |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Demo en Vivo
|
||||||
|
|
||||||
|
**Pruébelo ahora:** https://demo.langbot.dev/
|
||||||
|
- Correo electrónico: `demo@langbot.app`
|
||||||
|
- Contraseña: `langbot123456`
|
||||||
|
|
||||||
|
*Nota: Entorno de demostración público. No ingrese información confidencial.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comunidad
|
||||||
|
|
||||||
|
[](https://discord.gg/wdNEHETs87)
|
||||||
|
|
||||||
|
- [Comunidad de Discord](https://discord.gg/wdNEHETs87)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Historial de Stars
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Colaboradores
|
||||||
|
|
||||||
|
Gracias a todos los [colaboradores](https://github.com/langbot-app/LangBot/graphs/contributors) que han ayudado a mejorar LangBot:
|
||||||
|
|
||||||
|
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||||
|
</a>
|
||||||
176
README_FR.md
Normal file
176
README_FR.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://langbot.app">
|
||||||
|
<img width="130" src="res/logo-blue.png" alt="LangBot"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
|
<h3>Plateforme de niveau production pour construire des bots de messagerie instantanée avec agents IA.</h3>
|
||||||
|
<h4>Créez, déboguez et déployez rapidement des bots IA sur Slack, Discord, Telegram, WeChat et plus.</h4>
|
||||||
|
|
||||||
|
[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / Français / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||||
|
|
||||||
|
[](https://discord.gg/wdNEHETs87)
|
||||||
|
[](https://deepwiki.com/langbot-app/LangBot)
|
||||||
|
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||||
|
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||||
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
|
<a href="https://langbot.app">Accueil</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/guide">Documentation</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
|
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
||||||
|
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Qu'est-ce que LangBot ?
|
||||||
|
|
||||||
|
LangBot est une **plateforme open-source de niveau production** pour créer des bots de messagerie instantanée alimentés par l'IA. Elle connecte les grands modèles de langage (LLMs) à n'importe quelle plateforme de chat, vous permettant de créer des agents intelligents capables de converser, d'exécuter des tâches et de s'intégrer à vos workflows existants.
|
||||||
|
|
||||||
|
### Capacités Clés
|
||||||
|
|
||||||
|
- **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||||
|
- **Support Universel des Plateformes de MI** — Un seul code pour Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||||
|
- **Prêt pour la Production** — Contrôle d'accès, limitation de débit, filtrage de mots sensibles, surveillance complète et gestion des exceptions. Approuvé par les entreprises.
|
||||||
|
- **Écosystème de Plugins** — Des centaines de plugins, architecture événementielle, extensions de composants, et support du [protocole MCP](https://modelcontextprotocol.io/).
|
||||||
|
- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.
|
||||||
|
- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.
|
||||||
|
|
||||||
|
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Démarrage Rapide
|
||||||
|
|
||||||
|
### ☁️ LangBot Cloud (Recommandé)
|
||||||
|
|
||||||
|
**[LangBot Cloud](https://space.langbot.app/cloud)** — Sans déploiement, prêt à utiliser.
|
||||||
|
|
||||||
|
### Lancement en une ligne
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx langbot
|
||||||
|
```
|
||||||
|
|
||||||
|
> Nécessite [uv](https://docs.astral.sh/uv/getting-started/installation/). Visitez http://localhost:5300 — c'est prêt.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/langbot-app/LangBot
|
||||||
|
cd LangBot/docker
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Déploiement Cloud en un Clic
|
||||||
|
|
||||||
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
|
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plateformes Supportées
|
||||||
|
|
||||||
|
| Plateforme | Statut | Notes |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| Discord | ✅ | Officiel |
|
||||||
|
| Telegram | ✅ | Officiel |
|
||||||
|
| Slack | ✅ | Officiel |
|
||||||
|
| LINE | ✅ | Officiel |
|
||||||
|
| QQ | ✅ | Personnel & API Officielle (Canal, DM, Groupe) |
|
||||||
|
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
|
||||||
|
| WeChat | ✅ | Personnel & Compte Officiel |
|
||||||
|
| Lark | ✅ | Officiel |
|
||||||
|
| DingTalk | ✅ | Officiel |
|
||||||
|
| KOOK | ✅ | Officiel |
|
||||||
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Prend en charge plusieurs plateformes via ponts, comme Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, etc. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LLMs et Intégrations Supportés
|
||||||
|
|
||||||
|
| Fournisseur | Type | Statut |
|
||||||
|
|----------|------|--------|
|
||||||
|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
||||||
|
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
||||||
|
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
||||||
|
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
||||||
|
| [xAI](https://x.ai/) | LLM | ✅ |
|
||||||
|
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
||||||
|
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
||||||
|
| [Ollama](https://ollama.com/) | LLM Local | ✅ |
|
||||||
|
| [LM Studio](https://lmstudio.ai/) | LLM Local | ✅ |
|
||||||
|
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
||||||
|
| [MCP](https://modelcontextprotocol.io/) | Protocole | ✅ |
|
||||||
|
| [SiliconFlow](https://siliconflow.cn/) | Passerelle | ✅ |
|
||||||
|
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Passerelle | ✅ |
|
||||||
|
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Passerelle | ✅ |
|
||||||
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Passerelle | ✅ |
|
||||||
|
| [GiteeAI](https://ai.gitee.com/) | Passerelle | ✅ |
|
||||||
|
| [接口 AI](https://jiekou.ai/) | Passerelle | ✅ |
|
||||||
|
| [302.AI](https://share.302.ai/SuTG99) | Passerelle | ✅ |
|
||||||
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
|
||||||
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
|
||||||
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
|
||||||
|
|
||||||
|
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pourquoi LangBot ?
|
||||||
|
|
||||||
|
| Cas d'Usage | Comment LangBot Aide |
|
||||||
|
|----------|-------------------|
|
||||||
|
| **Support Client** | Déployez des agents IA sur Slack/Discord/Telegram qui répondent aux questions en utilisant votre base de connaissances |
|
||||||
|
| **Outils Internes** | Connectez les workflows n8n/Dify à WeCom/DingTalk pour automatiser vos processus métier |
|
||||||
|
| **Gestion de Communauté** | Modérez les groupes QQ/Discord avec un filtrage de contenu et des interactions alimentés par l'IA |
|
||||||
|
| **Présence Multi-plateforme** | Un seul bot, toutes les plateformes. Gérez tout depuis un tableau de bord unique |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Démo en Ligne
|
||||||
|
|
||||||
|
**Essayez maintenant :** https://demo.langbot.dev/
|
||||||
|
- Email : `demo@langbot.app`
|
||||||
|
- Mot de passe : `langbot123456`
|
||||||
|
|
||||||
|
*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Communauté
|
||||||
|
|
||||||
|
[](https://discord.gg/wdNEHETs87)
|
||||||
|
|
||||||
|
- [Communauté Discord](https://discord.gg/wdNEHETs87)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Historique des Stars
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributeurs
|
||||||
|
|
||||||
|
Merci à tous les [contributeurs](https://github.com/langbot-app/LangBot/graphs/contributors) qui ont aidé à améliorer LangBot :
|
||||||
|
|
||||||
|
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||||
|
</a>
|
||||||
221
README_JP.md
221
README_JP.md
@@ -1,31 +1,70 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://langbot.app">
|
<a href="https://langbot.app">
|
||||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
<img width="130" src="res/logo-blue.png" alt="LangBot"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / 日本語 / (PR for your language)
|
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
|
<h3>AIエージェント搭載IMボットを構築するための本番グレードプラットフォーム。</h3>
|
||||||
|
<h4>Slack、Discord、Telegram、WeChat などに AI ボットを素早く構築、デバッグ、デプロイ。</h4>
|
||||||
|
|
||||||
|
[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / 日本語 / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||||
|
|
||||||
[](https://discord.gg/wdNEHETs87)
|
[](https://discord.gg/wdNEHETs87)
|
||||||
[](https://deepwiki.com/langbot-app/LangBot)
|
[](https://deepwiki.com/langbot-app/LangBot)
|
||||||
[](https://github.com/langbot-app/LangBot/releases/latest)
|
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||||
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">ホーム</a> |
|
<a href="https://langbot.app">ホーム</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">デプロイ</a> |
|
<a href="https://link.langbot.app/ja/docs/features">機能</a> |
|
||||||
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">プラグイン</a> |
|
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a> |
|
||||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">プラグインの提出</a>
|
<a href="https://link.langbot.app/ja/docs/api">API</a> |
|
||||||
|
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
||||||
|
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
LangBot は、エージェント、RAG、MCP などの LLM アプリケーション機能を備えた、オープンソースの LLM ネイティブのインスタントメッセージングロボット開発プラットフォームです。世界中のインスタントメッセージングプラットフォームに適応し、豊富な API インターフェースを提供し、カスタム開発をサポートします。
|
---
|
||||||
|
|
||||||
## 📦 始め方
|
## LangBot とは?
|
||||||
|
|
||||||
#### Docker Compose デプロイ
|
LangBot は、AI搭載のインスタントメッセージングボットを構築するための**オープンソースの本番グレードプラットフォーム**です。大規模言語モデル(LLM)をあらゆるチャットプラットフォームに接続し、会話、タスク実行、既存のワークフローとの統合が可能なインテリジェントエージェントを作成できます。
|
||||||
|
|
||||||
|
### 主な機能
|
||||||
|
|
||||||
|
- **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAG(ナレッジベース)を内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com) と深く統合。
|
||||||
|
- **ユニバーサルIMプラットフォーム対応** — 単一のコードベースで Discord、Telegram、Slack、LINE、QQ、WeChat、WeCom、Lark、DingTalk、KOOK に対応。
|
||||||
|
- **本番環境対応** — アクセス制御、レート制限、センシティブワードフィルタリング、包括的な監視、例外処理を搭載。エンタープライズの信頼に応える品質。
|
||||||
|
- **プラグインエコシステム** — 数百のプラグイン、イベント駆動アーキテクチャ、コンポーネント拡張、[MCPプロトコル](https://modelcontextprotocol.io/)対応。
|
||||||
|
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
|
||||||
|
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
|
||||||
|
|
||||||
|
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
|
||||||
|
|
||||||
|
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## クイックスタート
|
||||||
|
|
||||||
|
### ☁️ LangBot Cloud(推奨)
|
||||||
|
|
||||||
|
**[LangBot Cloud](https://space.langbot.app/cloud)** — デプロイ不要、すぐに使えます。
|
||||||
|
|
||||||
|
### ワンライン起動
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx langbot
|
||||||
|
```
|
||||||
|
|
||||||
|
> [uv](https://docs.astral.sh/uv/getting-started/installation/) が必要です。http://localhost:5300 にアクセスして完了。
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/langbot-app/LangBot
|
git clone https://github.com/langbot-app/LangBot
|
||||||
@@ -33,98 +72,104 @@ cd LangBot/docker
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
http://localhost:5300 にアクセスして使用を開始します。
|
### ワンクリッククラウドデプロイ
|
||||||
|
|
||||||
詳細なドキュメントは[Dockerデプロイ](https://docs.langbot.app/en/deploy/langbot/docker.html)を参照してください。
|
|
||||||
|
|
||||||
#### Panelでのワンクリックデプロイ
|
|
||||||
|
|
||||||
LangBotはBTPanelにリストされています。BTPanelをインストールしている場合は、[ドキュメント](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html)を使用して使用できます。
|
|
||||||
|
|
||||||
#### Zeaburクラウドデプロイ
|
|
||||||
|
|
||||||
コミュニティが提供するZeaburテンプレート。
|
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
|
||||||
#### Railwayクラウドデプロイ
|
|
||||||
|
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
#### その他のデプロイ方法
|
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
リリースバージョンを直接使用して実行します。[手動デプロイ](https://docs.langbot.app/en/deploy/langbot/manual.html)のドキュメントを参照してください。
|
---
|
||||||
|
|
||||||
#### Kubernetes デプロイ
|
## 対応プラットフォーム
|
||||||
|
|
||||||
[Kubernetes デプロイ](./docker/README_K8S.md) ドキュメントを参照してください。
|
|
||||||
|
|
||||||
## 😎 最新情報を入手
|
|
||||||
|
|
||||||
リポジトリの右上にある Star と Watch ボタンをクリックして、最新の更新を取得してください。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## ✨ 機能
|
|
||||||
|
|
||||||
- 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル、ストリーミング出力機能をサポート、RAG(知識ベース)を組み込み、[Dify](https://dify.ai) と深く統合。
|
|
||||||
- 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram など、複数のプラットフォームをサポートしています。
|
|
||||||
- 🛠️ 高い安定性、豊富な機能: ネイティブのアクセス制御、レート制限、敏感な単語のフィルタリングなどのメカニズムをサポート。使いやすく、複数のデプロイ方法をサポート。複数のパイプライン設定をサポートし、異なるボットを異なる用途に使用できます。
|
|
||||||
- 🧩 プラグイン拡張、活発なコミュニティ: イベント駆動、コンポーネント拡張などのプラグインメカニズムをサポート。適配 Anthropic [MCP プロトコル](https://modelcontextprotocol.io/);豊富なエコシステム、現在数百のプラグインが存在。
|
|
||||||
- 😻 Web UI: ブラウザを通じてLangBotインスタンスを管理することをサポート。
|
|
||||||
|
|
||||||
詳細な仕様については、[ドキュメント](https://docs.langbot.app/en/insight/features.html)を参照してください。
|
|
||||||
|
|
||||||
または、デモ環境にアクセスしてください: https://demo.langbot.dev/
|
|
||||||
- ログイン情報: メール: `demo@langbot.app` パスワード: `langbot123456`
|
|
||||||
- 注意: WebUI のデモンストレーションのみの場合、公開環境では機密情報を入力しないでください。
|
|
||||||
|
|
||||||
### メッセージプラットフォーム
|
|
||||||
|
|
||||||
| プラットフォーム | ステータス | 備考 |
|
| プラットフォーム | ステータス | 備考 |
|
||||||
| --- | --- | --- |
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | 公式 |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | 公式 |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | 公式 |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | 公式 |
|
||||||
| 個人QQ | ✅ | |
|
| QQ | ✅ | 個人・公式API(チャンネル・DM・グループ) |
|
||||||
| QQ公式API | ✅ | |
|
| WeCom | ✅ | 企業WeChat、外部CS、AIボット |
|
||||||
| WeCom | ✅ | |
|
| WeChat | ✅ | 個人・公式アカウント |
|
||||||
| WeComCS | ✅ | |
|
| Lark | ✅ | 公式 |
|
||||||
| WeCom AI Bot | ✅ | |
|
| DingTalk | ✅ | 公式 |
|
||||||
| 個人WeChat | ✅ | |
|
| KOOK | ✅ | 公式 |
|
||||||
| Lark | ✅ | |
|
| Satori | ✅ | |
|
||||||
| DingTalk | ✅ | |
|
| Email | ✅ | Matrix、Satori |
|
||||||
|
| Matrix | ✅ | Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip など複数のブリッジ先プラットフォームに対応 |
|
||||||
|
|
||||||
### LLMs
|
---
|
||||||
|
|
||||||
| LLM | ステータス | 備考 |
|
## 対応LLMと統合
|
||||||
| --- | --- | --- |
|
|
||||||
| [OpenAI](https://platform.openai.com/) | ✅ | 任意のOpenAIインターフェース形式モデルに対応 |
|
|
||||||
| [DeepSeek](https://www.deepseek.com/) | ✅ | |
|
|
||||||
| [Moonshot](https://www.moonshot.cn/) | ✅ | |
|
|
||||||
| [Anthropic](https://www.anthropic.com/) | ✅ | |
|
|
||||||
| [xAI](https://x.ai/) | ✅ | |
|
|
||||||
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | |
|
|
||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | 大模型とGPUリソースプラットフォーム |
|
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型とGPUリソースプラットフォーム |
|
|
||||||
| [接口 AI](https://jiekou.ai/) | ✅ | LLMゲートウェイ(MaaS) |
|
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | ✅ | LLMとGPUリソースプラットフォーム |
|
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | ✅ | LLMゲートウェイ(MaaS) |
|
|
||||||
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
|
|
||||||
| [Dify](https://dify.ai) | ✅ | LLMOpsプラットフォーム |
|
|
||||||
| [Ollama](https://ollama.com/) | ✅ | ローカルLLM実行プラットフォーム |
|
|
||||||
| [LMStudio](https://lmstudio.ai/) | ✅ | ローカルLLM実行プラットフォーム |
|
|
||||||
| [GiteeAI](https://ai.gitee.com/) | ✅ | LLMインターフェースゲートウェイ(MaaS) |
|
|
||||||
| [SiliconFlow](https://siliconflow.cn/) | ✅ | LLMゲートウェイ(MaaS) |
|
|
||||||
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | ✅ | LLMゲートウェイ(MaaS), LLMOpsプラットフォーム |
|
|
||||||
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | LLMゲートウェイ(MaaS), LLMOpsプラットフォーム |
|
|
||||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | LLMゲートウェイ(MaaS) |
|
|
||||||
| [MCP](https://modelcontextprotocol.io/) | ✅ | MCPプロトコルをサポート |
|
|
||||||
|
|
||||||
## 🤝 コミュニティ貢献
|
| プロバイダー | タイプ | ステータス |
|
||||||
|
|----------|------|--------|
|
||||||
|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
||||||
|
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
||||||
|
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
||||||
|
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
||||||
|
| [xAI](https://x.ai/) | LLM | ✅ |
|
||||||
|
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
||||||
|
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
||||||
|
| [Ollama](https://ollama.com/) | ローカルLLM | ✅ |
|
||||||
|
| [LM Studio](https://lmstudio.ai/) | ローカルLLM | ✅ |
|
||||||
|
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
||||||
|
| [MCP](https://modelcontextprotocol.io/) | プロトコル | ✅ |
|
||||||
|
| [SiliconFlow](https://siliconflow.cn/) | ゲートウェイ | ✅ |
|
||||||
|
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | ゲートウェイ | ✅ |
|
||||||
|
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ゲートウェイ | ✅ |
|
||||||
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ゲートウェイ | ✅ |
|
||||||
|
| [GiteeAI](https://ai.gitee.com/) | ゲートウェイ | ✅ |
|
||||||
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPUプラットフォーム | ✅ |
|
||||||
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPUプラットフォーム | ✅ |
|
||||||
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
|
||||||
|
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
||||||
|
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
||||||
|
|
||||||
LangBot への貢献に対して、以下の [コード貢献者](https://github.com/langbot-app/LangBot/graphs/contributors) とコミュニティの他のメンバーに感謝します。
|
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## なぜ LangBot?
|
||||||
|
|
||||||
|
| ユースケース | LangBot の活用方法 |
|
||||||
|
|----------|-------------------|
|
||||||
|
| **カスタマーサポート** | ナレッジベースを活用して質問に回答するAIエージェントをSlack/Discord/Telegramにデプロイ |
|
||||||
|
| **社内ツール** | n8n/Difyのワークフローを WeCom/DingTalk に接続し、業務プロセスを自動化 |
|
||||||
|
| **コミュニティ管理** | AI搭載のコンテンツフィルタリングとインタラクションでQQ/Discordグループをモデレーション |
|
||||||
|
| **マルチプラットフォーム展開** | 1つのボットで全プラットフォームに対応。単一のダッシュボードから管理 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ライブデモ
|
||||||
|
|
||||||
|
**今すぐ試す:** https://demo.langbot.dev/
|
||||||
|
- メール: `demo@langbot.app`
|
||||||
|
- パスワード: `langbot123456`
|
||||||
|
|
||||||
|
*注意: 公開デモ環境です。機密情報を入力しないでください。*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## コミュニティ
|
||||||
|
|
||||||
|
[](https://discord.gg/wdNEHETs87)
|
||||||
|
|
||||||
|
- [Discord コミュニティ](https://discord.gg/wdNEHETs87)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Star 推移
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## コントリビューター
|
||||||
|
|
||||||
|
LangBot をより良くするために貢献してくださったすべての[コントリビューター](https://github.com/langbot-app/LangBot/graphs/contributors)に感謝します:
|
||||||
|
|
||||||
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||||
|
|||||||
176
README_KO.md
Normal file
176
README_KO.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://langbot.app">
|
||||||
|
<img width="130" src="res/logo-blue.png" alt="LangBot"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
|
<h3>AI 에이전트 IM 봇 구축을 위한 프로덕션 등급 플랫폼.</h3>
|
||||||
|
<h4>Slack, Discord, Telegram, WeChat 등에 AI 봇을 빠르게 구축, 디버그 및 배포.</h4>
|
||||||
|
|
||||||
|
[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / 한국어 / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||||
|
|
||||||
|
[](https://discord.gg/wdNEHETs87)
|
||||||
|
[](https://deepwiki.com/langbot-app/LangBot)
|
||||||
|
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||||
|
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||||
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
|
<a href="https://langbot.app">홈</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/features">기능</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/guide">문서</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
|
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
||||||
|
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LangBot이란?
|
||||||
|
|
||||||
|
LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈소스 프로덕션 등급 플랫폼**입니다. 대규모 언어 모델(LLM)을 모든 채팅 플랫폼에 연결하여 대화, 작업 실행, 기존 워크플로우와의 통합이 가능한 지능형 에이전트를 만들 수 있습니다.
|
||||||
|
|
||||||
|
### 핵심 기능
|
||||||
|
|
||||||
|
- **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com) 심층 통합.
|
||||||
|
- **유니버설 IM 플랫폼 지원** — 단일 코드베이스로 Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK 지원.
|
||||||
|
- **프로덕션 레디** — 접근 제어, 속도 제한, 민감어 필터링, 종합 모니터링 및 예외 처리. 기업 환경에서 검증됨.
|
||||||
|
- **플러그인 생태계** — 수백 개의 플러그인, 이벤트 기반 아키텍처, 컴포넌트 확장, [MCP 프로토콜](https://modelcontextprotocol.io/) 지원.
|
||||||
|
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
|
||||||
|
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
|
||||||
|
|
||||||
|
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 빠른 시작
|
||||||
|
|
||||||
|
### ☁️ LangBot Cloud (추천)
|
||||||
|
|
||||||
|
**[LangBot Cloud](https://space.langbot.app/cloud)** — 배포 없이 바로 사용.
|
||||||
|
|
||||||
|
### 원라인 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx langbot
|
||||||
|
```
|
||||||
|
|
||||||
|
> [uv](https://docs.astral.sh/uv/getting-started/installation/) 설치 필요. http://localhost:5300 방문 — 완료.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/langbot-app/LangBot
|
||||||
|
cd LangBot/docker
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 원클릭 클라우드 배포
|
||||||
|
|
||||||
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
|
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 지원 플랫폼
|
||||||
|
|
||||||
|
| 플랫폼 | 상태 | 비고 |
|
||||||
|
|--------|------|------|
|
||||||
|
| Discord | ✅ | 공식 |
|
||||||
|
| Telegram | ✅ | 공식 |
|
||||||
|
| Slack | ✅ | 공식 |
|
||||||
|
| LINE | ✅ | 공식 |
|
||||||
|
| QQ | ✅ | 개인 및 공식 API (채널, DM, 그룹) |
|
||||||
|
| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
|
||||||
|
| WeChat | ✅ | 개인 및 공식 계정 |
|
||||||
|
| Lark | ✅ | 공식 |
|
||||||
|
| DingTalk | ✅ | 공식 |
|
||||||
|
| KOOK | ✅ | 공식 |
|
||||||
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip 등 여러 브리지 플랫폼 지원 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 지원 LLM 및 통합
|
||||||
|
|
||||||
|
| 제공자 | 유형 | 상태 |
|
||||||
|
|--------|------|------|
|
||||||
|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
||||||
|
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
||||||
|
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
||||||
|
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
||||||
|
| [xAI](https://x.ai/) | LLM | ✅ |
|
||||||
|
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
||||||
|
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
||||||
|
| [Ollama](https://ollama.com/) | 로컬 LLM | ✅ |
|
||||||
|
| [LM Studio](https://lmstudio.ai/) | 로컬 LLM | ✅ |
|
||||||
|
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
||||||
|
| [MCP](https://modelcontextprotocol.io/) | 프로토콜 | ✅ |
|
||||||
|
| [SiliconFlow](https://siliconflow.cn/) | 게이트웨이 | ✅ |
|
||||||
|
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | 게이트웨이 | ✅ |
|
||||||
|
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | 게이트웨이 | ✅ |
|
||||||
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | 게이트웨이 | ✅ |
|
||||||
|
| [GiteeAI](https://ai.gitee.com/) | 게이트웨이 | ✅ |
|
||||||
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 플랫폼 | ✅ |
|
||||||
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 플랫폼 | ✅ |
|
||||||
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
|
||||||
|
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
||||||
|
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
||||||
|
|
||||||
|
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 왜 LangBot인가?
|
||||||
|
|
||||||
|
| 사용 사례 | LangBot 활용 방법 |
|
||||||
|
|-----------|-------------------|
|
||||||
|
| **고객 지원** | 지식 베이스를 활용하여 질문에 답변하는 AI 에이전트를 Slack/Discord/Telegram에 배포 |
|
||||||
|
| **내부 도구** | n8n/Dify 워크플로우를 WeCom/DingTalk에 연결하여 비즈니스 프로세스 자동화 |
|
||||||
|
| **커뮤니티 관리** | AI 기반 콘텐츠 필터링 및 상호작용으로 QQ/Discord 그룹 관리 |
|
||||||
|
| **멀티 플랫폼** | 하나의 봇으로 모든 플랫폼 지원. 단일 대시보드에서 관리 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 라이브 데모
|
||||||
|
|
||||||
|
**지금 체험:** https://demo.langbot.dev/
|
||||||
|
- 이메일: `demo@langbot.app`
|
||||||
|
- 비밀번호: `langbot123456`
|
||||||
|
|
||||||
|
*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 커뮤니티
|
||||||
|
|
||||||
|
[](https://discord.gg/wdNEHETs87)
|
||||||
|
|
||||||
|
- [Discord 커뮤니티](https://discord.gg/wdNEHETs87)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Star 추이
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기여자
|
||||||
|
|
||||||
|
LangBot을 더 나은 프로젝트로 만들어 주신 모든 [기여자](https://github.com/langbot-app/LangBot/graphs/contributors)분들께 감사드립니다:
|
||||||
|
|
||||||
|
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||||
|
</a>
|
||||||
176
README_RU.md
Normal file
176
README_RU.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://langbot.app">
|
||||||
|
<img width="130" src="res/logo-blue.png" alt="LangBot"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
|
<h3>Платформа производственного уровня для создания агентных IM-ботов.</h3>
|
||||||
|
<h4>Быстро создавайте, отлаживайте и развертывайте ИИ-ботов в Slack, Discord, Telegram, WeChat и других платформах.</h4>
|
||||||
|
|
||||||
|
[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / Русский / [Tiếng Việt](README_VI.md)
|
||||||
|
|
||||||
|
[](https://discord.gg/wdNEHETs87)
|
||||||
|
[](https://deepwiki.com/langbot-app/LangBot)
|
||||||
|
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||||
|
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||||
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
|
<a href="https://langbot.app">Главная</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/features">Возможности</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/guide">Документация</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
|
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
||||||
|
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что такое LangBot?
|
||||||
|
|
||||||
|
LangBot — это **платформа с открытым исходным кодом производственного уровня** для создания ИИ-ботов в мессенджерах. Она связывает большие языковые модели (LLM) с любой чат-платформой, позволяя создавать интеллектуальных агентов, которые могут вести диалоги, выполнять задачи и интегрироваться с вашими существующими рабочими процессами.
|
||||||
|
|
||||||
|
### Ключевые возможности
|
||||||
|
|
||||||
|
- **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||||
|
- **Универсальная поддержка IM-платформ** — Единая кодовая база для Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||||
|
- **Готовность к продакшену** — Контроль доступа, ограничение скорости, фильтрация чувствительных слов, комплексный мониторинг и обработка исключений. Проверено в корпоративной среде.
|
||||||
|
- **Экосистема плагинов** — Сотни плагинов, событийно-ориентированная архитектура, расширения компонентов и поддержка [протокола MCP](https://modelcontextprotocol.io/).
|
||||||
|
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
|
||||||
|
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
|
||||||
|
|
||||||
|
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### ☁️ LangBot Cloud (Рекомендуется)
|
||||||
|
|
||||||
|
**[LangBot Cloud](https://space.langbot.app/cloud)** — Без развёртывания, готово к использованию.
|
||||||
|
|
||||||
|
### Запуск одной командой
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx langbot
|
||||||
|
```
|
||||||
|
|
||||||
|
> Требуется [uv](https://docs.astral.sh/uv/getting-started/installation/). Откройте http://localhost:5300 — готово.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/langbot-app/LangBot
|
||||||
|
cd LangBot/docker
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Облачное развертывание одним кликом
|
||||||
|
|
||||||
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
|
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Поддерживаемые платформы
|
||||||
|
|
||||||
|
| Платформа | Статус | Примечания |
|
||||||
|
|-----------|--------|------------|
|
||||||
|
| Discord | ✅ | Официальный |
|
||||||
|
| Telegram | ✅ | Официальный |
|
||||||
|
| Slack | ✅ | Официальный |
|
||||||
|
| LINE | ✅ | Официальный |
|
||||||
|
| QQ | ✅ | Личный и официальный API (Канал, ЛС, Группа) |
|
||||||
|
| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот |
|
||||||
|
| WeChat | ✅ | Личный и официальный аккаунт |
|
||||||
|
| Lark | ✅ | Официальный |
|
||||||
|
| DingTalk | ✅ | Официальный |
|
||||||
|
| KOOK | ✅ | Официальный |
|
||||||
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Поддерживает несколько платформ через мосты, включая Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip и другие |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Поддерживаемые LLM и интеграции
|
||||||
|
|
||||||
|
| Провайдер | Тип | Статус |
|
||||||
|
|-----------|-----|--------|
|
||||||
|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
||||||
|
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
||||||
|
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
||||||
|
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
||||||
|
| [xAI](https://x.ai/) | LLM | ✅ |
|
||||||
|
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
||||||
|
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
||||||
|
| [Ollama](https://ollama.com/) | Локальный LLM | ✅ |
|
||||||
|
| [LM Studio](https://lmstudio.ai/) | Локальный LLM | ✅ |
|
||||||
|
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
||||||
|
| [MCP](https://modelcontextprotocol.io/) | Протокол | ✅ |
|
||||||
|
| [SiliconFlow](https://siliconflow.cn/) | Шлюз | ✅ |
|
||||||
|
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Шлюз | ✅ |
|
||||||
|
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Шлюз | ✅ |
|
||||||
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Шлюз | ✅ |
|
||||||
|
| [GiteeAI](https://ai.gitee.com/) | Шлюз | ✅ |
|
||||||
|
| [302.AI](https://share.302.ai/SuTG99) | Шлюз | ✅ |
|
||||||
|
| [接口 AI](https://jiekou.ai/) | Шлюз | ✅ |
|
||||||
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
|
||||||
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
||||||
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
|
||||||
|
|
||||||
|
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Почему LangBot?
|
||||||
|
|
||||||
|
| Сценарий использования | Как помогает LangBot |
|
||||||
|
|------------------------|----------------------|
|
||||||
|
| **Поддержка клиентов** | Разверните ИИ-агентов в Slack/Discord/Telegram, которые отвечают на вопросы, используя вашу базу знаний |
|
||||||
|
| **Внутренние инструменты** | Подключите рабочие процессы n8n/Dify к WeCom/DingTalk для автоматизации бизнес-процессов |
|
||||||
|
| **Управление сообществом** | Модерируйте группы QQ/Discord с помощью ИИ-фильтрации контента и взаимодействия |
|
||||||
|
| **Мультиплатформенное присутствие** | Один бот — все платформы. Управляйте из единой панели |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Демо
|
||||||
|
|
||||||
|
**Попробуйте прямо сейчас:** https://demo.langbot.dev/
|
||||||
|
- Email: `demo@langbot.app`
|
||||||
|
- Пароль: `langbot123456`
|
||||||
|
|
||||||
|
*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сообщество
|
||||||
|
|
||||||
|
[](https://discord.gg/wdNEHETs87)
|
||||||
|
|
||||||
|
- [Сообщество Discord](https://discord.gg/wdNEHETs87)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## История Stars
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Участники
|
||||||
|
|
||||||
|
Спасибо всем [участникам](https://github.com/langbot-app/LangBot/graphs/contributors), которые помогли сделать LangBot лучше:
|
||||||
|
|
||||||
|
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||||
|
</a>
|
||||||
235
README_TW.md
235
README_TW.md
@@ -1,33 +1,72 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://langbot.app">
|
<a href="https://langbot.app">
|
||||||
<img src="https://docs.langbot.app/social_zh.png" alt="LangBot"/>
|
<img width="130" src="res/logo-blue.png" alt="LangBot"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div align="center"><a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<div align="center">
|
||||||
|
|
||||||
[English](README_EN.md) / [简体中文](README.md) / 繁體中文 / [日本語](README_JP.md) / (PR for your language)
|
<a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
|
<h3>生產級 AI 即時通訊機器人開發平台。</h3>
|
||||||
|
<h4>快速建構、除錯和部署 AI 機器人到微信、QQ、飛書、Slack、Discord、Telegram 等平台。</h4>
|
||||||
|
|
||||||
|
[English](README.md) / [简体中文](README_CN.md) / 繁體中文 / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||||
|
|
||||||
[](https://discord.gg/wdNEHETs87)
|
[](https://discord.gg/wdNEHETs87)
|
||||||
[](https://qm.qq.com/q/JLi38whHum)
|
[](https://qm.qq.com/q/JLi38whHum)
|
||||||
[](https://deepwiki.com/langbot-app/LangBot)
|
[](https://deepwiki.com/langbot-app/LangBot)
|
||||||
[](https://github.com/langbot-app/LangBot/releases/latest)
|
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||||
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
[](https://gitcode.com/RockChinQ/LangBot)
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
<a href="https://langbot.app">主頁</a> |
|
<a href="https://langbot.app">官網</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/guide.html">部署文件</a> |
|
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||||
<a href="https://docs.langbot.app/zh/plugin/plugin-intro.html">外掛介紹</a> |
|
<a href="https://link.langbot.app/zh/docs/guide">文件</a> |
|
||||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">提交外掛</a>
|
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||||
|
<a href="https://space.langbot.app">外掛市場</a> |
|
||||||
|
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
LangBot 是一個開源的大語言模型原生即時通訊機器人開發平台,旨在提供開箱即用的 IM 機器人開發體驗,具有 Agent、RAG、MCP 等多種 LLM 應用功能,適配全球主流即時通訊平台,並提供豐富的 API 介面,支援自定義開發。
|
---
|
||||||
|
|
||||||
## 📦 開始使用
|
## 什麼是 LangBot?
|
||||||
|
|
||||||
#### Docker Compose 部署
|
LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時通訊機器人。它將大語言模型(LLM)連接到各種聊天平台,幫助你創建能夠對話、執行任務、並整合到現有工作流程中的智能 Agent。
|
||||||
|
|
||||||
|
### 核心能力
|
||||||
|
|
||||||
|
- **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG(知識庫),深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、 [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。
|
||||||
|
- **全平台支援** — 一套程式碼,覆蓋 QQ、微信、企業微信、飛書、釘釘、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||||
|
- **生產就緒** — 存取控制、限速、敏感詞過濾、全面監控與異常處理,已被多家企業採用。
|
||||||
|
- **外掛生態** — 數百個外掛,事件驅動架構,組件擴展,適配 [MCP 協議](https://modelcontextprotocol.io/)。
|
||||||
|
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
|
||||||
|
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
|
||||||
|
|
||||||
|
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
|
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://blog.langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://blog.langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://blog.langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://blog.langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速開始
|
||||||
|
|
||||||
|
### ☁️ LangBot Cloud(推薦)
|
||||||
|
|
||||||
|
**[LangBot Cloud](https://space.langbot.app/cloud)** — 免部署,開箱即用。
|
||||||
|
|
||||||
|
### 一鍵啟動
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx langbot
|
||||||
|
```
|
||||||
|
|
||||||
|
> 需要安裝 [uv](https://docs.astral.sh/uv/getting-started/installation/)。訪問 http://localhost:5300 即可使用。
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/langbot-app/LangBot
|
git clone https://github.com/langbot-app/LangBot
|
||||||
@@ -35,99 +74,66 @@ cd LangBot/docker
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
訪問 http://localhost:5300 即可開始使用。
|
### 一鍵雲端部署
|
||||||
|
|
||||||
詳細文件[Docker 部署](https://docs.langbot.app/zh/deploy/langbot/docker.html)。
|
|
||||||
|
|
||||||
#### 寶塔面板部署
|
|
||||||
|
|
||||||
已上架寶塔面板,若您已安裝寶塔面板,可以根據[文件](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html)使用。
|
|
||||||
|
|
||||||
#### Zeabur 雲端部署
|
|
||||||
|
|
||||||
社群貢獻的 Zeabur 模板。
|
|
||||||
|
|
||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
|
|
||||||
#### Railway 雲端部署
|
|
||||||
|
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
#### 手動部署
|
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
直接使用發行版運行,查看文件[手動部署](https://docs.langbot.app/zh/deploy/langbot/manual.html)。
|
---
|
||||||
|
|
||||||
#### Kubernetes 部署
|
## 支援的平台
|
||||||
|
|
||||||
參考 [Kubernetes 部署](./docker/README_K8S.md) 文件。
|
|
||||||
|
|
||||||
## 😎 保持更新
|
|
||||||
|
|
||||||
點擊倉庫右上角 Star 和 Watch 按鈕,獲取最新動態。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## ✨ 特性
|
|
||||||
|
|
||||||
- 💬 大模型對話、Agent:支援多種大模型,適配群聊和私聊;具有多輪對話、工具調用、多模態、流式輸出能力,自帶 RAG(知識庫)實現,並深度適配 [Dify](https://dify.ai)。
|
|
||||||
- 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram 等平台。
|
|
||||||
- 🛠️ 高穩定性、功能完備:原生支援訪問控制、限速、敏感詞過濾等機制;配置簡單,支援多種部署方式。支援多流水線配置,不同機器人用於不同應用場景。
|
|
||||||
- 🧩 外掛擴展、活躍社群:支援事件驅動、組件擴展等外掛機制;適配 Anthropic [MCP 協議](https://modelcontextprotocol.io/);目前已有數百個外掛。
|
|
||||||
- 😻 Web 管理面板:支援通過瀏覽器管理 LangBot 實例,不再需要手動編寫配置文件。
|
|
||||||
|
|
||||||
詳細規格特性請訪問[文件](https://docs.langbot.app/zh/insight/features.html)。
|
|
||||||
|
|
||||||
或訪問 demo 環境:https://demo.langbot.dev/
|
|
||||||
- 登入資訊:郵箱:`demo@langbot.app` 密碼:`langbot123456`
|
|
||||||
- 注意:僅展示 WebUI 效果,公開環境,請不要在其中填入您的任何敏感資訊。
|
|
||||||
|
|
||||||
### 訊息平台
|
|
||||||
|
|
||||||
| 平台 | 狀態 | 備註 |
|
| 平台 | 狀態 | 備註 |
|
||||||
| --- | --- | --- |
|
|------|------|------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | 官方 |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | 官方 |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | 官方 |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | 官方 |
|
||||||
| QQ 個人號 | ✅ | QQ 個人號私聊、群聊 |
|
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
|
||||||
| QQ 官方機器人 | ✅ | QQ 官方機器人,支援頻道、私聊、群聊 |
|
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
|
||||||
| 微信 | ✅ | |
|
| 微信 | ✅ | 個人微信、微信公眾號 |
|
||||||
| 企微對外客服 | ✅ | |
|
| 飛書 | ✅ | 官方 |
|
||||||
| 企微智能機器人 | ✅ | |
|
| 釘釘 | ✅ | 官方 |
|
||||||
| 微信公眾號 | ✅ | |
|
| KOOK | ✅ | 官方 |
|
||||||
| Lark | ✅ | |
|
| Satori | ✅ | |
|
||||||
| DingTalk | ✅ | |
|
| Email | ✅ | 只 Matrix、Satori |
|
||||||
|
| Matrix | ✅ | 支援多種橋接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
||||||
|
|
||||||
### 大模型能力
|
---
|
||||||
|
|
||||||
| 模型 | 狀態 | 備註 |
|
## 支援的大模型與整合
|
||||||
| --- | --- | --- |
|
|
||||||
| [OpenAI](https://platform.openai.com/) | ✅ | 可接入任何 OpenAI 介面格式模型 |
|
|
||||||
| [DeepSeek](https://www.deepseek.com/) | ✅ | |
|
|
||||||
| [Moonshot](https://www.moonshot.cn/) | ✅ | |
|
|
||||||
| [Anthropic](https://www.anthropic.com/) | ✅ | |
|
|
||||||
| [xAI](https://x.ai/) | ✅ | |
|
|
||||||
| [智譜AI](https://open.bigmodel.cn/) | ✅ | |
|
|
||||||
| [勝算雲](https://www.shengsuanyun.com/?from=CH_KYIPP758) | ✅ | 大模型和 GPU 資源平台 |
|
|
||||||
| [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | 大模型和 GPU 資源平台 |
|
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型和 GPU 資源平台 |
|
|
||||||
| [接口 AI](https://jiekou.ai/) | ✅ | 大模型聚合平台,專注全球大模型接入 |
|
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | ✅ | 大模型聚合平台 |
|
|
||||||
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
|
|
||||||
| [Dify](https://dify.ai) | ✅ | LLMOps 平台 |
|
|
||||||
| [Ollama](https://ollama.com/) | ✅ | 本地大模型運行平台 |
|
|
||||||
| [LMStudio](https://lmstudio.ai/) | ✅ | 本地大模型運行平台 |
|
|
||||||
| [GiteeAI](https://ai.gitee.com/) | ✅ | 大模型介面聚合平台 |
|
|
||||||
| [SiliconFlow](https://siliconflow.cn/) | ✅ | 大模型聚合平台 |
|
|
||||||
| [阿里雲百煉](https://bailian.console.aliyun.com/) | ✅ | 大模型聚合平台, LLMOps 平台 |
|
|
||||||
| [火山方舟](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | 大模型聚合平台, LLMOps 平台 |
|
|
||||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | 大模型聚合平台 |
|
|
||||||
| [MCP](https://modelcontextprotocol.io/) | ✅ | 支援通過 MCP 協議獲取工具 |
|
|
||||||
|
|
||||||
### TTS
|
| 提供商 | 類型 | 狀態 |
|
||||||
|
|--------|------|------|
|
||||||
|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
||||||
|
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
||||||
|
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
||||||
|
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
||||||
|
| [xAI](https://x.ai/) | LLM | ✅ |
|
||||||
|
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
||||||
|
| [智譜AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
||||||
|
| [Ollama](https://ollama.com/) | 本地 LLM | ✅ |
|
||||||
|
| [LM Studio](https://lmstudio.ai/) | 本地 LLM | ✅ |
|
||||||
|
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
||||||
|
| [MCP](https://modelcontextprotocol.io/) | 協議 | ✅ |
|
||||||
|
| [SiliconFlow](https://siliconflow.cn/) | 聚合平台 | ✅ |
|
||||||
|
| [阿里雲百煉](https://bailian.console.aliyun.com/) | 聚合平台 | ✅ |
|
||||||
|
| [火山方舟](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | 聚合平台 | ✅ |
|
||||||
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | 聚合平台 | ✅ |
|
||||||
|
| [GiteeAI](https://ai.gitee.com/) | 聚合平台 | ✅ |
|
||||||
|
| [勝算雲](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 平台 | ✅ |
|
||||||
|
| [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |
|
||||||
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
||||||
|
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
||||||
|
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|
||||||
|
### TTS(語音合成)
|
||||||
|
|
||||||
| 平台/模型 | 備註 |
|
| 平台/模型 | 備註 |
|
||||||
| --- | --- |
|
|-----------|------|
|
||||||
| [FishAudio](https://fish.audio/zh-CN/discovery/) | [外掛](https://github.com/the-lazy-me/NewChatVoice) |
|
| [FishAudio](https://fish.audio/zh-CN/discovery/) | [外掛](https://github.com/the-lazy-me/NewChatVoice) |
|
||||||
| [海豚 AI](https://www.ttson.cn/?source=thelazy) | [外掛](https://github.com/the-lazy-me/NewChatVoice) |
|
| [海豚 AI](https://www.ttson.cn/?source=thelazy) | [外掛](https://github.com/the-lazy-me/NewChatVoice) |
|
||||||
| [AzureTTS](https://portal.azure.com/) | [外掛](https://github.com/Ingnaryk/LangBot_AzureTTS) |
|
| [AzureTTS](https://portal.azure.com/) | [外掛](https://github.com/Ingnaryk/LangBot_AzureTTS) |
|
||||||
@@ -135,12 +141,53 @@ docker compose up -d
|
|||||||
### 文生圖
|
### 文生圖
|
||||||
|
|
||||||
| 平台/模型 | 備註 |
|
| 平台/模型 | 備註 |
|
||||||
| --- | --- |
|
|-----------|------|
|
||||||
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin)
|
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
||||||
|
|
||||||
## 😘 社群貢獻
|
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
感謝以下[程式碼貢獻者](https://github.com/langbot-app/LangBot/graphs/contributors)和社群裡其他成員對 LangBot 的貢獻:
|
---
|
||||||
|
|
||||||
|
## 為什麼選擇 LangBot?
|
||||||
|
|
||||||
|
| 使用場景 | LangBot 如何幫助 |
|
||||||
|
|----------|------------------|
|
||||||
|
| **客戶服務** | 將 AI Agent 部署到微信/企微/釘釘/飛書,基於知識庫自動回答使用者問題 |
|
||||||
|
| **內部工具** | 將 n8n/Dify 工作流接入企微/釘釘,實現業務流程自動化 |
|
||||||
|
| **社群運營** | 在 QQ/Discord 群中使用 AI 驅動的內容審核與智能互動 |
|
||||||
|
| **多平台觸達** | 一個機器人,覆蓋所有平台。透過統一面板集中管理 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 線上演示
|
||||||
|
|
||||||
|
**立即體驗:** https://demo.langbot.dev/
|
||||||
|
- 信箱:`demo@langbot.app`
|
||||||
|
- 密碼:`langbot123456`
|
||||||
|
|
||||||
|
*注意:公開演示環境,請不要在其中填入任何敏感資訊。*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 社群
|
||||||
|
|
||||||
|
[](https://discord.gg/wdNEHETs87)
|
||||||
|
[](https://qm.qq.com/q/JLi38whHum)
|
||||||
|
|
||||||
|
- [Discord 社群](https://discord.gg/wdNEHETs87)
|
||||||
|
- [QQ 社群群](https://qm.qq.com/q/JLi38whHum)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Star 趨勢
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 貢獻者
|
||||||
|
|
||||||
|
感謝所有[貢獻者](https://github.com/langbot-app/LangBot/graphs/contributors)對 LangBot 的幫助:
|
||||||
|
|
||||||
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||||
|
|||||||
176
README_VI.md
Normal file
176
README_VI.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://langbot.app">
|
||||||
|
<img width="130" src="res/logo-blue.png" alt="LangBot"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
|
<h3>Nền tảng cấp sản xuất để xây dựng bot IM với AI agent.</h3>
|
||||||
|
<h4>Xây dựng, gỡ lỗi và triển khai bot AI nhanh chóng trên Slack, Discord, Telegram, WeChat và nhiều nền tảng khác.</h4>
|
||||||
|
|
||||||
|
[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / Tiếng Việt
|
||||||
|
|
||||||
|
[](https://discord.gg/wdNEHETs87)
|
||||||
|
[](https://deepwiki.com/langbot-app/LangBot)
|
||||||
|
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||||
|
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||||
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
|
<a href="https://langbot.app">Trang chủ</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/features">Tính năng</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a> |
|
||||||
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
|
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
||||||
|
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LangBot là gì?
|
||||||
|
|
||||||
|
LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để xây dựng bot nhắn tin tức thời được hỗ trợ bởi AI. Nó kết nối các Mô hình Ngôn ngữ Lớn (LLM) với bất kỳ nền tảng chat nào, cho phép bạn tạo các agent thông minh có thể trò chuyện, thực hiện tác vụ và tích hợp với quy trình làm việc hiện có của bạn.
|
||||||
|
|
||||||
|
### Khả năng chính
|
||||||
|
|
||||||
|
- **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||||
|
- **Hỗ trợ đa nền tảng IM** — Một mã nguồn cho Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||||
|
- **Sẵn sàng cho sản xuất** — Kiểm soát truy cập, giới hạn tốc độ, lọc từ nhạy cảm, giám sát toàn diện và xử lý ngoại lệ. Được doanh nghiệp tin dùng.
|
||||||
|
- **Hệ sinh thái Plugin** — Hàng trăm plugin, kiến trúc hướng sự kiện, mở rộng thành phần, và hỗ trợ [giao thức MCP](https://modelcontextprotocol.io/).
|
||||||
|
- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.
|
||||||
|
- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.
|
||||||
|
|
||||||
|
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bắt đầu nhanh
|
||||||
|
|
||||||
|
### ☁️ LangBot Cloud (Khuyên dùng)
|
||||||
|
|
||||||
|
**[LangBot Cloud](https://space.langbot.app/cloud)** — Không cần triển khai, sẵn sàng sử dụng.
|
||||||
|
|
||||||
|
### Khởi chạy một dòng
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx langbot
|
||||||
|
```
|
||||||
|
|
||||||
|
> Yêu cầu [uv](https://docs.astral.sh/uv/getting-started/installation/). Truy cập http://localhost:5300 — xong.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/langbot-app/LangBot
|
||||||
|
cd LangBot/docker
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Triển khai đám mây một cú nhấp
|
||||||
|
|
||||||
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
|
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nền tảng được hỗ trợ
|
||||||
|
|
||||||
|
| Nền tảng | Trạng thái | Ghi chú |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| Discord | ✅ | Chính thức |
|
||||||
|
| Telegram | ✅ | Chính thức |
|
||||||
|
| Slack | ✅ | Chính thức |
|
||||||
|
| LINE | ✅ | Chính thức |
|
||||||
|
| QQ | ✅ | Cá nhân & API chính thức (Kênh, DM, Nhóm) |
|
||||||
|
| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot |
|
||||||
|
| WeChat | ✅ | Cá nhân & Tài khoản công khai |
|
||||||
|
| Lark | ✅ | Chính thức |
|
||||||
|
| DingTalk | ✅ | Chính thức |
|
||||||
|
| KOOK | ✅ | Chính thức |
|
||||||
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Hỗ trợ nhiều nền tảng qua bridge như Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip và hơn thế nữa |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LLM và tích hợp được hỗ trợ
|
||||||
|
|
||||||
|
| Nhà cung cấp | Loại | Trạng thái |
|
||||||
|
|----------|------|--------|
|
||||||
|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
||||||
|
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
||||||
|
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
||||||
|
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
||||||
|
| [xAI](https://x.ai/) | LLM | ✅ |
|
||||||
|
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
||||||
|
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
||||||
|
| [Ollama](https://ollama.com/) | LLM cục bộ | ✅ |
|
||||||
|
| [LM Studio](https://lmstudio.ai/) | LLM cục bộ | ✅ |
|
||||||
|
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
||||||
|
| [MCP](https://modelcontextprotocol.io/) | Giao thức | ✅ |
|
||||||
|
| [SiliconFlow](https://siliconflow.cn/) | Cổng | ✅ |
|
||||||
|
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Cổng | ✅ |
|
||||||
|
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Cổng | ✅ |
|
||||||
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Cổng | ✅ |
|
||||||
|
| [GiteeAI](https://ai.gitee.com/) | Cổng | ✅ |
|
||||||
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Nền tảng GPU | ✅ |
|
||||||
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Nền tảng GPU | ✅ |
|
||||||
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
|
||||||
|
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
||||||
|
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |
|
||||||
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
||||||
|
|
||||||
|
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tại sao chọn LangBot?
|
||||||
|
|
||||||
|
| Trường hợp sử dụng | LangBot giúp như thế nào |
|
||||||
|
|----------|-------------------|
|
||||||
|
| **Hỗ trợ khách hàng** | Triển khai agent AI trên Slack/Discord/Telegram để trả lời câu hỏi bằng cơ sở kiến thức của bạn |
|
||||||
|
| **Công cụ nội bộ** | Kết nối quy trình n8n/Dify với WeCom/DingTalk để tự động hóa quy trình kinh doanh |
|
||||||
|
| **Quản lý cộng đồng** | Quản lý nhóm QQ/Discord với tính năng lọc nội dung và tương tác được hỗ trợ bởi AI |
|
||||||
|
| **Đa nền tảng** | Một bot, tất cả nền tảng. Quản lý từ một bảng điều khiển duy nhất |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Demo trực tuyến
|
||||||
|
|
||||||
|
**Thử ngay:** https://demo.langbot.dev/
|
||||||
|
- Email: `demo@langbot.app`
|
||||||
|
- Mật khẩu: `langbot123456`
|
||||||
|
|
||||||
|
*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cộng đồng
|
||||||
|
|
||||||
|
[](https://discord.gg/wdNEHETs87)
|
||||||
|
|
||||||
|
- [Cộng đồng Discord](https://discord.gg/wdNEHETs87)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lịch sử Star
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Người đóng góp
|
||||||
|
|
||||||
|
Cảm ơn tất cả [người đóng góp](https://github.com/langbot-app/LangBot/graphs/contributors) đã giúp LangBot trở nên tốt hơn:
|
||||||
|
|
||||||
|
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||||
|
</a>
|
||||||
@@ -1,629 +0,0 @@
|
|||||||
# LangBot Kubernetes 部署指南 / Kubernetes Deployment Guide
|
|
||||||
|
|
||||||
[简体中文](#简体中文) | [English](#english)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 简体中文
|
|
||||||
|
|
||||||
### 概述
|
|
||||||
|
|
||||||
本指南提供了在 Kubernetes 集群中部署 LangBot 的完整步骤。Kubernetes 部署配置基于 `docker-compose.yaml`,适用于生产环境的容器化部署。
|
|
||||||
|
|
||||||
### 前置要求
|
|
||||||
|
|
||||||
- Kubernetes 集群(版本 1.19+)
|
|
||||||
- `kubectl` 命令行工具已配置并可访问集群
|
|
||||||
- 集群中有可用的存储类(StorageClass)用于持久化存储(可选但推荐)
|
|
||||||
- 至少 2 vCPU 和 4GB RAM 的可用资源
|
|
||||||
|
|
||||||
### 架构说明
|
|
||||||
|
|
||||||
Kubernetes 部署包含以下组件:
|
|
||||||
|
|
||||||
1. **langbot**: 主应用服务
|
|
||||||
- 提供 Web UI(端口 5300)
|
|
||||||
- 处理平台 webhook(端口 2280-2290)
|
|
||||||
- 数据持久化卷
|
|
||||||
|
|
||||||
2. **langbot-plugin-runtime**: 插件运行时服务
|
|
||||||
- WebSocket 通信(端口 5400)
|
|
||||||
- 插件数据持久化卷
|
|
||||||
|
|
||||||
3. **持久化存储**:
|
|
||||||
- `langbot-data`: LangBot 主数据
|
|
||||||
- `langbot-plugins`: 插件文件
|
|
||||||
- `langbot-plugin-runtime-data`: 插件运行时数据
|
|
||||||
|
|
||||||
### 快速开始
|
|
||||||
|
|
||||||
#### 1. 下载部署文件
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 克隆仓库
|
|
||||||
git clone https://github.com/langbot-app/LangBot
|
|
||||||
cd LangBot/docker
|
|
||||||
|
|
||||||
# 或直接下载 kubernetes.yaml
|
|
||||||
wget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 部署到 Kubernetes
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 应用所有配置
|
|
||||||
kubectl apply -f kubernetes.yaml
|
|
||||||
|
|
||||||
# 检查部署状态
|
|
||||||
kubectl get all -n langbot
|
|
||||||
|
|
||||||
# 查看 Pod 日志
|
|
||||||
kubectl logs -n langbot -l app=langbot -f
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. 访问 LangBot
|
|
||||||
|
|
||||||
默认情况下,LangBot 服务使用 ClusterIP 类型,只能在集群内部访问。您可以选择以下方式之一来访问:
|
|
||||||
|
|
||||||
**选项 A: 端口转发(推荐用于测试)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl port-forward -n langbot svc/langbot 5300:5300
|
|
||||||
```
|
|
||||||
|
|
||||||
然后访问 http://localhost:5300
|
|
||||||
|
|
||||||
**选项 B: NodePort(适用于开发环境)**
|
|
||||||
|
|
||||||
编辑 `kubernetes.yaml`,取消注释 NodePort Service 部分,然后:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f kubernetes.yaml
|
|
||||||
# 获取节点 IP
|
|
||||||
kubectl get nodes -o wide
|
|
||||||
# 访问 http://<NODE_IP>:30300
|
|
||||||
```
|
|
||||||
|
|
||||||
**选项 C: LoadBalancer(适用于云环境)**
|
|
||||||
|
|
||||||
编辑 `kubernetes.yaml`,取消注释 LoadBalancer Service 部分,然后:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f kubernetes.yaml
|
|
||||||
# 获取外部 IP
|
|
||||||
kubectl get svc -n langbot langbot-loadbalancer
|
|
||||||
# 访问 http://<EXTERNAL_IP>
|
|
||||||
```
|
|
||||||
|
|
||||||
**选项 D: Ingress(推荐用于生产环境)**
|
|
||||||
|
|
||||||
确保集群中已安装 Ingress Controller(如 nginx-ingress),然后:
|
|
||||||
|
|
||||||
1. 编辑 `kubernetes.yaml` 中的 Ingress 配置
|
|
||||||
2. 修改域名为您的实际域名
|
|
||||||
3. 应用配置:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f kubernetes.yaml
|
|
||||||
# 访问 http://langbot.yourdomain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### 配置说明
|
|
||||||
|
|
||||||
#### 环境变量
|
|
||||||
|
|
||||||
在 `ConfigMap` 中配置环境变量:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: langbot-config
|
|
||||||
namespace: langbot
|
|
||||||
data:
|
|
||||||
TZ: "Asia/Shanghai" # 修改为您的时区
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 存储配置
|
|
||||||
|
|
||||||
默认使用动态存储分配。如果您有特定的 StorageClass,请在 PVC 中指定:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
spec:
|
|
||||||
storageClassName: your-storage-class-name
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 10Gi
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 资源限制
|
|
||||||
|
|
||||||
根据您的需求调整资源限制:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "1Gi"
|
|
||||||
cpu: "500m"
|
|
||||||
limits:
|
|
||||||
memory: "4Gi"
|
|
||||||
cpu: "2000m"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 常用操作
|
|
||||||
|
|
||||||
#### 查看日志
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看 LangBot 主服务日志
|
|
||||||
kubectl logs -n langbot -l app=langbot -f
|
|
||||||
|
|
||||||
# 查看插件运行时日志
|
|
||||||
kubectl logs -n langbot -l app=langbot-plugin-runtime -f
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 重启服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 重启 LangBot
|
|
||||||
kubectl rollout restart deployment/langbot -n langbot
|
|
||||||
|
|
||||||
# 重启插件运行时
|
|
||||||
kubectl rollout restart deployment/langbot-plugin-runtime -n langbot
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 更新镜像
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 更新到最新版本
|
|
||||||
kubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest
|
|
||||||
kubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest
|
|
||||||
|
|
||||||
# 检查更新状态
|
|
||||||
kubectl rollout status deployment/langbot -n langbot
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 扩容(不推荐)
|
|
||||||
|
|
||||||
注意:由于 LangBot 使用 ReadWriteOnce 的持久化存储,不支持多副本扩容。如需高可用,请考虑使用 ReadWriteMany 存储或其他架构方案。
|
|
||||||
|
|
||||||
#### 备份数据
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 备份 PVC 数据
|
|
||||||
kubectl exec -n langbot -it <langbot-pod-name> -- tar czf /tmp/backup.tar.gz /app/data
|
|
||||||
kubectl cp langbot/<langbot-pod-name>:/tmp/backup.tar.gz ./backup.tar.gz
|
|
||||||
```
|
|
||||||
|
|
||||||
### 卸载
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 删除所有资源(保留 PVC)
|
|
||||||
kubectl delete deployment,service,configmap -n langbot --all
|
|
||||||
|
|
||||||
# 删除 PVC(会删除数据)
|
|
||||||
kubectl delete pvc -n langbot --all
|
|
||||||
|
|
||||||
# 删除命名空间
|
|
||||||
kubectl delete namespace langbot
|
|
||||||
```
|
|
||||||
|
|
||||||
### 故障排查
|
|
||||||
|
|
||||||
#### Pod 无法启动
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看 Pod 状态
|
|
||||||
kubectl get pods -n langbot
|
|
||||||
|
|
||||||
# 查看详细信息
|
|
||||||
kubectl describe pod -n langbot <pod-name>
|
|
||||||
|
|
||||||
# 查看事件
|
|
||||||
kubectl get events -n langbot --sort-by='.lastTimestamp'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 存储问题
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查 PVC 状态
|
|
||||||
kubectl get pvc -n langbot
|
|
||||||
|
|
||||||
# 检查 PV
|
|
||||||
kubectl get pv
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 网络访问问题
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查 Service
|
|
||||||
kubectl get svc -n langbot
|
|
||||||
|
|
||||||
# 检查端口转发
|
|
||||||
kubectl port-forward -n langbot svc/langbot 5300:5300
|
|
||||||
```
|
|
||||||
|
|
||||||
### 生产环境建议
|
|
||||||
|
|
||||||
1. **使用特定版本标签**:避免使用 `latest` 标签,使用具体版本号如 `rockchin/langbot:v1.0.0`
|
|
||||||
2. **配置资源限制**:根据实际负载调整 CPU 和内存限制
|
|
||||||
3. **使用 Ingress + TLS**:配置 HTTPS 访问和证书管理
|
|
||||||
4. **配置监控和告警**:集成 Prometheus、Grafana 等监控工具
|
|
||||||
5. **定期备份**:配置自动备份策略保护数据
|
|
||||||
6. **使用专用 StorageClass**:为生产环境配置高性能存储
|
|
||||||
7. **配置亲和性规则**:确保 Pod 调度到合适的节点
|
|
||||||
|
|
||||||
### 高级配置
|
|
||||||
|
|
||||||
#### 使用 Secrets 管理敏感信息
|
|
||||||
|
|
||||||
如果需要配置 API 密钥等敏感信息:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: langbot-secrets
|
|
||||||
namespace: langbot
|
|
||||||
type: Opaque
|
|
||||||
data:
|
|
||||||
api_key: <base64-encoded-value>
|
|
||||||
```
|
|
||||||
|
|
||||||
然后在 Deployment 中引用:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
env:
|
|
||||||
- name: API_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: langbot-secrets
|
|
||||||
key: api_key
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 配置水平自动扩缩容(HPA)
|
|
||||||
|
|
||||||
注意:需要确保使用 ReadWriteMany 存储类型
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: autoscaling/v2
|
|
||||||
kind: HorizontalPodAutoscaler
|
|
||||||
metadata:
|
|
||||||
name: langbot-hpa
|
|
||||||
namespace: langbot
|
|
||||||
spec:
|
|
||||||
scaleTargetRef:
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
name: langbot
|
|
||||||
minReplicas: 1
|
|
||||||
maxReplicas: 3
|
|
||||||
metrics:
|
|
||||||
- type: Resource
|
|
||||||
resource:
|
|
||||||
name: cpu
|
|
||||||
target:
|
|
||||||
type: Utilization
|
|
||||||
averageUtilization: 70
|
|
||||||
```
|
|
||||||
|
|
||||||
### 参考资源
|
|
||||||
|
|
||||||
- [LangBot 官方文档](https://docs.langbot.app)
|
|
||||||
- [Docker 部署文档](https://docs.langbot.app/zh/deploy/langbot/docker.html)
|
|
||||||
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
|
|
||||||
This guide provides complete steps for deploying LangBot in a Kubernetes cluster. The Kubernetes deployment configuration is based on `docker-compose.yaml` and is suitable for production containerized deployments.
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Kubernetes cluster (version 1.19+)
|
|
||||||
- `kubectl` command-line tool configured with cluster access
|
|
||||||
- Available StorageClass in the cluster for persistent storage (optional but recommended)
|
|
||||||
- At least 2 vCPU and 4GB RAM of available resources
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
The Kubernetes deployment includes the following components:
|
|
||||||
|
|
||||||
1. **langbot**: Main application service
|
|
||||||
- Provides Web UI (port 5300)
|
|
||||||
- Handles platform webhooks (ports 2280-2290)
|
|
||||||
- Data persistence volume
|
|
||||||
|
|
||||||
2. **langbot-plugin-runtime**: Plugin runtime service
|
|
||||||
- WebSocket communication (port 5400)
|
|
||||||
- Plugin data persistence volume
|
|
||||||
|
|
||||||
3. **Persistent Storage**:
|
|
||||||
- `langbot-data`: LangBot main data
|
|
||||||
- `langbot-plugins`: Plugin files
|
|
||||||
- `langbot-plugin-runtime-data`: Plugin runtime data
|
|
||||||
|
|
||||||
### Quick Start
|
|
||||||
|
|
||||||
#### 1. Download Deployment Files
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone repository
|
|
||||||
git clone https://github.com/langbot-app/LangBot
|
|
||||||
cd LangBot/docker
|
|
||||||
|
|
||||||
# Or download kubernetes.yaml directly
|
|
||||||
wget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Deploy to Kubernetes
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Apply all configurations
|
|
||||||
kubectl apply -f kubernetes.yaml
|
|
||||||
|
|
||||||
# Check deployment status
|
|
||||||
kubectl get all -n langbot
|
|
||||||
|
|
||||||
# View Pod logs
|
|
||||||
kubectl logs -n langbot -l app=langbot -f
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Access LangBot
|
|
||||||
|
|
||||||
By default, LangBot service uses ClusterIP type, accessible only within the cluster. Choose one of the following methods to access:
|
|
||||||
|
|
||||||
**Option A: Port Forwarding (Recommended for testing)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl port-forward -n langbot svc/langbot 5300:5300
|
|
||||||
```
|
|
||||||
|
|
||||||
Then visit http://localhost:5300
|
|
||||||
|
|
||||||
**Option B: NodePort (Suitable for development)**
|
|
||||||
|
|
||||||
Edit `kubernetes.yaml`, uncomment the NodePort Service section, then:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f kubernetes.yaml
|
|
||||||
# Get node IP
|
|
||||||
kubectl get nodes -o wide
|
|
||||||
# Visit http://<NODE_IP>:30300
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option C: LoadBalancer (Suitable for cloud environments)**
|
|
||||||
|
|
||||||
Edit `kubernetes.yaml`, uncomment the LoadBalancer Service section, then:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f kubernetes.yaml
|
|
||||||
# Get external IP
|
|
||||||
kubectl get svc -n langbot langbot-loadbalancer
|
|
||||||
# Visit http://<EXTERNAL_IP>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option D: Ingress (Recommended for production)**
|
|
||||||
|
|
||||||
Ensure an Ingress Controller (e.g., nginx-ingress) is installed in the cluster, then:
|
|
||||||
|
|
||||||
1. Edit the Ingress configuration in `kubernetes.yaml`
|
|
||||||
2. Change the domain to your actual domain
|
|
||||||
3. Apply configuration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f kubernetes.yaml
|
|
||||||
# Visit http://langbot.yourdomain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
#### Environment Variables
|
|
||||||
|
|
||||||
Configure environment variables in ConfigMap:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: langbot-config
|
|
||||||
namespace: langbot
|
|
||||||
data:
|
|
||||||
TZ: "Asia/Shanghai" # Change to your timezone
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Storage Configuration
|
|
||||||
|
|
||||||
Uses dynamic storage provisioning by default. If you have a specific StorageClass, specify it in PVC:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
spec:
|
|
||||||
storageClassName: your-storage-class-name
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 10Gi
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Resource Limits
|
|
||||||
|
|
||||||
Adjust resource limits based on your needs:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "1Gi"
|
|
||||||
cpu: "500m"
|
|
||||||
limits:
|
|
||||||
memory: "4Gi"
|
|
||||||
cpu: "2000m"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Operations
|
|
||||||
|
|
||||||
#### View Logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# View LangBot main service logs
|
|
||||||
kubectl logs -n langbot -l app=langbot -f
|
|
||||||
|
|
||||||
# View plugin runtime logs
|
|
||||||
kubectl logs -n langbot -l app=langbot-plugin-runtime -f
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Restart Services
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Restart LangBot
|
|
||||||
kubectl rollout restart deployment/langbot -n langbot
|
|
||||||
|
|
||||||
# Restart plugin runtime
|
|
||||||
kubectl rollout restart deployment/langbot-plugin-runtime -n langbot
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Update Images
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Update to latest version
|
|
||||||
kubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest
|
|
||||||
kubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest
|
|
||||||
|
|
||||||
# Check update status
|
|
||||||
kubectl rollout status deployment/langbot -n langbot
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Scaling (Not Recommended)
|
|
||||||
|
|
||||||
Note: Due to LangBot using ReadWriteOnce persistent storage, multi-replica scaling is not supported. For high availability, consider using ReadWriteMany storage or alternative architectures.
|
|
||||||
|
|
||||||
#### Backup Data
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Backup PVC data
|
|
||||||
kubectl exec -n langbot -it <langbot-pod-name> -- tar czf /tmp/backup.tar.gz /app/data
|
|
||||||
kubectl cp langbot/<langbot-pod-name>:/tmp/backup.tar.gz ./backup.tar.gz
|
|
||||||
```
|
|
||||||
|
|
||||||
### Uninstall
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Delete all resources (keep PVCs)
|
|
||||||
kubectl delete deployment,service,configmap -n langbot --all
|
|
||||||
|
|
||||||
# Delete PVCs (will delete data)
|
|
||||||
kubectl delete pvc -n langbot --all
|
|
||||||
|
|
||||||
# Delete namespace
|
|
||||||
kubectl delete namespace langbot
|
|
||||||
```
|
|
||||||
|
|
||||||
### Troubleshooting
|
|
||||||
|
|
||||||
#### Pods Not Starting
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check Pod status
|
|
||||||
kubectl get pods -n langbot
|
|
||||||
|
|
||||||
# View detailed information
|
|
||||||
kubectl describe pod -n langbot <pod-name>
|
|
||||||
|
|
||||||
# View events
|
|
||||||
kubectl get events -n langbot --sort-by='.lastTimestamp'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Storage Issues
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check PVC status
|
|
||||||
kubectl get pvc -n langbot
|
|
||||||
|
|
||||||
# Check PV
|
|
||||||
kubectl get pv
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Network Access Issues
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check Service
|
|
||||||
kubectl get svc -n langbot
|
|
||||||
|
|
||||||
# Test port forwarding
|
|
||||||
kubectl port-forward -n langbot svc/langbot 5300:5300
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Recommendations
|
|
||||||
|
|
||||||
1. **Use specific version tags**: Avoid using `latest` tag, use specific version like `rockchin/langbot:v1.0.0`
|
|
||||||
2. **Configure resource limits**: Adjust CPU and memory limits based on actual load
|
|
||||||
3. **Use Ingress + TLS**: Configure HTTPS access and certificate management
|
|
||||||
4. **Configure monitoring and alerts**: Integrate monitoring tools like Prometheus, Grafana
|
|
||||||
5. **Regular backups**: Configure automated backup strategy to protect data
|
|
||||||
6. **Use dedicated StorageClass**: Configure high-performance storage for production
|
|
||||||
7. **Configure affinity rules**: Ensure Pods are scheduled to appropriate nodes
|
|
||||||
|
|
||||||
### Advanced Configuration
|
|
||||||
|
|
||||||
#### Using Secrets for Sensitive Information
|
|
||||||
|
|
||||||
If you need to configure sensitive information like API keys:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: langbot-secrets
|
|
||||||
namespace: langbot
|
|
||||||
type: Opaque
|
|
||||||
data:
|
|
||||||
api_key: <base64-encoded-value>
|
|
||||||
```
|
|
||||||
|
|
||||||
Then reference in Deployment:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
env:
|
|
||||||
- name: API_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: langbot-secrets
|
|
||||||
key: api_key
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Configure Horizontal Pod Autoscaling (HPA)
|
|
||||||
|
|
||||||
Note: Requires ReadWriteMany storage type
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: autoscaling/v2
|
|
||||||
kind: HorizontalPodAutoscaler
|
|
||||||
metadata:
|
|
||||||
name: langbot-hpa
|
|
||||||
namespace: langbot
|
|
||||||
spec:
|
|
||||||
scaleTargetRef:
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
name: langbot
|
|
||||||
minReplicas: 1
|
|
||||||
maxReplicas: 3
|
|
||||||
metrics:
|
|
||||||
- type: Resource
|
|
||||||
resource:
|
|
||||||
name: cpu
|
|
||||||
target:
|
|
||||||
type: Utilization
|
|
||||||
averageUtilization: 70
|
|
||||||
```
|
|
||||||
|
|
||||||
### References
|
|
||||||
|
|
||||||
- [LangBot Official Documentation](https://docs.langbot.app)
|
|
||||||
- [Docker Deployment Guide](https://docs.langbot.app/zh/deploy/langbot/docker.html)
|
|
||||||
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Docker Compose configuration for LangBot
|
# Docker Compose configuration for LangBot
|
||||||
# For Kubernetes deployment, see kubernetes.yaml and README_K8S.md
|
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app
|
||||||
version: "3"
|
version: "3"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -14,7 +14,41 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
command: ["uv", "run", "-m", "langbot_plugin.cli.__init__", "rt"]
|
command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "rt"]
|
||||||
|
networks:
|
||||||
|
- langbot_network
|
||||||
|
|
||||||
|
# The Box sandbox runtime is optional. It is only started when you run
|
||||||
|
# ``docker compose --profile box up`` (or ``docker compose --profile all
|
||||||
|
# up``). With Box off, LangBot keeps the dashboard / skills list visible
|
||||||
|
# (read-only) but disables sandbox tools, skill add/edit and stdio MCP —
|
||||||
|
# set ``box.enabled: false`` in ``data/config.yaml`` (or
|
||||||
|
# ``BOX__ENABLED=false`` in the langbot service env below) to match.
|
||||||
|
langbot_box:
|
||||||
|
image: rockchin/langbot:latest
|
||||||
|
container_name: langbot_box
|
||||||
|
profiles: ["box", "all"]
|
||||||
|
volumes:
|
||||||
|
# Keep the source and target path identical because langbot_box uses the
|
||||||
|
# host Docker socket to create sandbox containers. Override
|
||||||
|
# LANGBOT_BOX_ROOT with an absolute path if you do not want the default.
|
||||||
|
- ${LANGBOT_BOX_ROOT:-${PWD}/data/box}:${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||||
|
# Mount container runtime socket for Box sandbox backend.
|
||||||
|
# Uncomment the one that matches your container runtime:
|
||||||
|
# - /var/run/podman/podman.sock:/var/run/podman/podman.sock # Podman
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock # Docker
|
||||||
|
restart: on-failure
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
# The Box runtime does NOT read box.local.* from config.yaml or env; it
|
||||||
|
# receives its configuration from LangBot via the INIT RPC action.
|
||||||
|
# Do not add LANGBOT_BOX_* / BOX__* here — they would be silently ignored.
|
||||||
|
# Launched through the same CLI entry point as the plugin runtime
|
||||||
|
# (`langbot_plugin.cli.__init__ <subcommand>`). WebSocket is the default
|
||||||
|
# control transport — mirrors `rt`, which also runs with no flag. Pass
|
||||||
|
# `-s` / `--stdio-control` only for the stdio mode LangBot uses outside
|
||||||
|
# containers.
|
||||||
|
command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "box"]
|
||||||
networks:
|
networks:
|
||||||
- langbot_network
|
- langbot_network
|
||||||
|
|
||||||
@@ -23,13 +57,19 @@ services:
|
|||||||
container_name: langbot
|
container_name: langbot
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./plugins:/app/plugins
|
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
|
# Unified env-override convention: SECTION__SUBSECTION__KEY overrides the
|
||||||
|
# matching config.yaml field (see LoadConfigStage). These map onto
|
||||||
|
# box.local.* and are forwarded to the Box runtime via INIT RPC.
|
||||||
|
- BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||||
|
- BOX__LOCAL__DEFAULT_WORKSPACE=default
|
||||||
|
- BOX__LOCAL__SKILLS_ROOT=skills
|
||||||
|
- BOX__LOCAL__ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||||
ports:
|
ports:
|
||||||
- 5300:5300 # For web ui
|
- 5300:5300 # For web ui and webhook callback
|
||||||
- 2280-2290:2280-2290 # For platform webhook
|
- 2280-2285:2280-2285 # For platform reverse connection
|
||||||
networks:
|
networks:
|
||||||
- langbot_network
|
- langbot_network
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Kubernetes Deployment for LangBot
|
# Kubernetes Deployment for LangBot
|
||||||
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
|
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
|
||||||
#
|
#
|
||||||
|
# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes
|
||||||
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# kubectl apply -f kubernetes.yaml
|
# kubectl apply -f kubernetes.yaml
|
||||||
#
|
#
|
||||||
@@ -8,13 +10,15 @@
|
|||||||
# - A Kubernetes cluster (1.19+)
|
# - A Kubernetes cluster (1.19+)
|
||||||
# - kubectl configured to communicate with your cluster
|
# - kubectl configured to communicate with your cluster
|
||||||
# - (Optional) A StorageClass for dynamic volume provisioning
|
# - (Optional) A StorageClass for dynamic volume provisioning
|
||||||
|
# - For the Box sandbox runtime: a node with a reachable Docker daemon
|
||||||
|
# (the box mounts the node's /var/run/docker.sock). See the deployment guide.
|
||||||
#
|
#
|
||||||
# Components:
|
# Components:
|
||||||
# - Namespace: langbot
|
# - Namespace: langbot
|
||||||
# - PersistentVolumeClaims for data persistence
|
# - PersistentVolumeClaims for data persistence
|
||||||
# - Deployments for langbot and langbot_plugin_runtime
|
# - Deployments for langbot, langbot-plugin-runtime, and langbot-box (sandbox)
|
||||||
# - Services for network access
|
# - Services for network access
|
||||||
# - ConfigMap for timezone configuration
|
# - ConfigMap for timezone + runtime endpoints
|
||||||
|
|
||||||
---
|
---
|
||||||
# Namespace
|
# Namespace
|
||||||
@@ -83,6 +87,11 @@ metadata:
|
|||||||
data:
|
data:
|
||||||
TZ: "Asia/Shanghai"
|
TZ: "Asia/Shanghai"
|
||||||
PLUGIN__RUNTIME_WS_URL: "ws://langbot-plugin-runtime:5400/control/ws"
|
PLUGIN__RUNTIME_WS_URL: "ws://langbot-plugin-runtime:5400/control/ws"
|
||||||
|
# Box sandbox runtime endpoint. LangBot connects to the Box runtime over
|
||||||
|
# WebSocket. The hostname MUST match the langbot-box Service name. Note the
|
||||||
|
# in-container default ("langbot_box") uses an underscore, which is an
|
||||||
|
# invalid Kubernetes DNS name — so the endpoint is always set explicitly here.
|
||||||
|
BOX__RUNTIME__ENDPOINT: "ws://langbot-box:5410"
|
||||||
|
|
||||||
---
|
---
|
||||||
# Deployment for LangBot Plugin Runtime
|
# Deployment for LangBot Plugin Runtime
|
||||||
@@ -169,6 +178,136 @@ spec:
|
|||||||
protocol: TCP
|
protocol: TCP
|
||||||
name: runtime
|
name: runtime
|
||||||
|
|
||||||
|
---
|
||||||
|
# Deployment for LangBot Box (sandbox) runtime
|
||||||
|
#
|
||||||
|
# The Box runtime backs LangBot's sandbox tools (exec / read / write / edit /
|
||||||
|
# glob / grep), the `activate` skill tool, skill add/edit, and stdio-mode MCP
|
||||||
|
# servers. It is OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on
|
||||||
|
# the langbot Deployment (or `box.enabled: false` in config.yaml) so the
|
||||||
|
# dashboard renders cleanly with sandbox features disabled.
|
||||||
|
#
|
||||||
|
# IMPORTANT — how the sandbox actually runs:
|
||||||
|
# The bundled image ships only the Docker CLI (no dockerd, no nsjail). The Box
|
||||||
|
# runtime therefore creates sandbox containers by talking to a Docker daemon
|
||||||
|
# over the mounted socket (`/var/run/docker.sock`). Because that daemon
|
||||||
|
# resolves bind-mount paths on the NODE filesystem, the Box workspace root
|
||||||
|
# must be the SAME absolute path inside the box container, inside every
|
||||||
|
# sandbox container it spawns, AND on the node. That is why this manifest uses
|
||||||
|
# a hostPath at a fixed absolute path (/app/data/box) and pins langbot + box
|
||||||
|
# to the same node via podAffinity. A normal PVC will NOT work for the box
|
||||||
|
# workspace, because the node's dockerd cannot see paths that exist only
|
||||||
|
# inside the pod's mount namespace.
|
||||||
|
#
|
||||||
|
# Security note: mounting the host Docker socket grants the Box runtime (and any
|
||||||
|
# code executed in the sandbox) effective root on the node. Only deploy Box on
|
||||||
|
# nodes you trust for this workload, ideally a dedicated node pool. For a
|
||||||
|
# stronger isolation boundary, switch box.backend to 'e2b' (set E2B_API_KEY) and
|
||||||
|
# drop the docker.sock mount + hostPath entirely.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: langbot-box
|
||||||
|
namespace: langbot
|
||||||
|
labels:
|
||||||
|
app: langbot-box
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: langbot-box
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: langbot-box
|
||||||
|
spec:
|
||||||
|
# Pin to the same node as langbot so they share the hostPath box root.
|
||||||
|
affinity:
|
||||||
|
podAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
- labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: langbot
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
containers:
|
||||||
|
- name: langbot-box
|
||||||
|
image: rockchin/langbot:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
# Launched through the same CLI entry point as the plugin runtime.
|
||||||
|
# No flag => WebSocket control transport (default), listening on 5410.
|
||||||
|
command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "box"]
|
||||||
|
ports:
|
||||||
|
- containerPort: 5410
|
||||||
|
name: box-rpc
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: TZ
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: langbot-config
|
||||||
|
key: TZ
|
||||||
|
# The Box runtime does NOT read box.local.* / BOX__* from its own env;
|
||||||
|
# it receives its configuration from LangBot via the INIT RPC action.
|
||||||
|
# Do not add BOX__* here — they would be silently ignored.
|
||||||
|
volumeMounts:
|
||||||
|
# Box workspace root — identical path on node, box, and sandbox
|
||||||
|
# containers (see the IMPORTANT note above).
|
||||||
|
- name: box-root
|
||||||
|
mountPath: /app/data/box
|
||||||
|
# Host Docker socket — the sandbox backend uses it to create containers.
|
||||||
|
- name: docker-sock
|
||||||
|
mountPath: /var/run/docker.sock
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
cpu: "1000m"
|
||||||
|
livenessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: 5410
|
||||||
|
initialDelaySeconds: 20
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: 5410
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
volumes:
|
||||||
|
- name: box-root
|
||||||
|
hostPath:
|
||||||
|
path: /app/data/box
|
||||||
|
type: DirectoryOrCreate
|
||||||
|
- name: docker-sock
|
||||||
|
hostPath:
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
type: Socket
|
||||||
|
restartPolicy: Always
|
||||||
|
|
||||||
|
---
|
||||||
|
# Service for LangBot Box runtime
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: langbot-box
|
||||||
|
namespace: langbot
|
||||||
|
labels:
|
||||||
|
app: langbot-box
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app: langbot-box
|
||||||
|
ports:
|
||||||
|
- port: 5410
|
||||||
|
targetPort: 5410
|
||||||
|
protocol: TCP
|
||||||
|
name: box-rpc
|
||||||
|
|
||||||
---
|
---
|
||||||
# Deployment for LangBot
|
# Deployment for LangBot
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
@@ -213,11 +352,36 @@ spec:
|
|||||||
configMapKeyRef:
|
configMapKeyRef:
|
||||||
name: langbot-config
|
name: langbot-config
|
||||||
key: PLUGIN__RUNTIME_WS_URL
|
key: PLUGIN__RUNTIME_WS_URL
|
||||||
|
# Box (sandbox) runtime endpoint. Connects LangBot to the langbot-box
|
||||||
|
# Service over WebSocket. Remove this (and the langbot-box Deployment)
|
||||||
|
# and set BOX__ENABLED=false if you do not want the sandbox.
|
||||||
|
- name: BOX__RUNTIME__ENDPOINT
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: langbot-config
|
||||||
|
key: BOX__RUNTIME__ENDPOINT
|
||||||
|
# box.local.* config — forwarded to the Box runtime via INIT RPC. The
|
||||||
|
# host_root MUST match the box-root hostPath mountPath below AND the box
|
||||||
|
# Deployment's box-root mountPath, so that skill package paths resolve
|
||||||
|
# identically on both sides and on the node's Docker daemon.
|
||||||
|
- name: BOX__LOCAL__HOST_ROOT
|
||||||
|
value: "/app/data/box"
|
||||||
|
- name: BOX__LOCAL__DEFAULT_WORKSPACE
|
||||||
|
value: "default"
|
||||||
|
- name: BOX__LOCAL__SKILLS_ROOT
|
||||||
|
value: "skills"
|
||||||
|
- name: BOX__LOCAL__ALLOWED_MOUNT_ROOTS
|
||||||
|
value: "/app/data/box"
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /app/data
|
mountPath: /app/data
|
||||||
- name: plugins
|
- name: plugins
|
||||||
mountPath: /app/plugins
|
mountPath: /app/plugins
|
||||||
|
# Same node-level box root as the langbot-box Deployment. Mounted over
|
||||||
|
# the data PVC's /app/data/box subpath so both LangBot and the Box
|
||||||
|
# runtime (and the node's dockerd) agree on one absolute path.
|
||||||
|
- name: box-root
|
||||||
|
mountPath: /app/data/box
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "1Gi"
|
memory: "1Gi"
|
||||||
@@ -250,6 +414,13 @@ spec:
|
|||||||
- name: plugins
|
- name: plugins
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: langbot-plugins
|
claimName: langbot-plugins
|
||||||
|
# Node-level box workspace root, shared with the langbot-box Deployment.
|
||||||
|
# hostPath (not PVC) because the node's Docker daemon must see the same
|
||||||
|
# absolute path when bind-mounting workspaces into sandbox containers.
|
||||||
|
- name: box-root
|
||||||
|
hostPath:
|
||||||
|
path: /app/data/box
|
||||||
|
type: DirectoryOrCreate
|
||||||
restartPolicy: Always
|
restartPolicy: Always
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
412
docs/MIGRATION_SUMMARY.md
Normal file
412
docs/MIGRATION_SUMMARY.md
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
# WebChat 到 WebSocket 迁移总结
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
已完全移除旧的基于SSE的WebChat系统,并替换为基于WebSocket的双向实时通信系统。这是一个内置在LangBot中的完整IM系统,支持流式输出。
|
||||||
|
|
||||||
|
## 已删除的文件
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- ❌ `src/langbot/pkg/api/http/controller/groups/pipelines/webchat.py` - 旧的SSE路由
|
||||||
|
- ❌ `src/langbot/pkg/platform/sources/webchat.py` - 旧的WebChat适配器
|
||||||
|
- ❌ `src/langbot/pkg/platform/sources/webchat.yaml` - 旧的配置文件
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- ❌ BackendClient中所有SSE相关代码已完全移除
|
||||||
|
- ❌ DebugDialog中所有SSE相关逻辑已完全替换
|
||||||
|
|
||||||
|
## 新增的文件
|
||||||
|
|
||||||
|
### 后端核心文件
|
||||||
|
|
||||||
|
**1. WebSocket连接管理器**
|
||||||
|
```
|
||||||
|
src/langbot/pkg/platform/sources/websocket_manager.py
|
||||||
|
```
|
||||||
|
- 管理所有并发WebSocket连接
|
||||||
|
- 线程安全的连接池
|
||||||
|
- 按流水线、会话类型分组
|
||||||
|
- 广播和单播消息功能
|
||||||
|
- 连接统计和监控
|
||||||
|
|
||||||
|
**2. WebSocket适配器**
|
||||||
|
```
|
||||||
|
src/langbot/pkg/platform/sources/websocket_adapter.py
|
||||||
|
```
|
||||||
|
- 实现平台适配器接口
|
||||||
|
- **完整流式支持** (`reply_message_chunk` 方法)
|
||||||
|
- 双向消息流处理
|
||||||
|
- 消息历史管理
|
||||||
|
- 会话管理
|
||||||
|
|
||||||
|
**3. WebSocket路由控制器**
|
||||||
|
```
|
||||||
|
src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py
|
||||||
|
```
|
||||||
|
- WebSocket端点处理
|
||||||
|
- REST API接口
|
||||||
|
- 心跳机制
|
||||||
|
- 连接生命周期管理
|
||||||
|
|
||||||
|
**4. 配置文件**
|
||||||
|
```
|
||||||
|
src/langbot/pkg/platform/sources/websocket.yaml
|
||||||
|
```
|
||||||
|
- WebSocket适配器元数据
|
||||||
|
|
||||||
|
### 前端核心文件
|
||||||
|
|
||||||
|
**1. WebSocket客户端**
|
||||||
|
```
|
||||||
|
web/src/app/infra/websocket/WebSocketClient.ts
|
||||||
|
```
|
||||||
|
- WebSocket连接管理
|
||||||
|
- 自动重连(最多5次)
|
||||||
|
- 心跳机制(30秒)
|
||||||
|
- 事件回调系统
|
||||||
|
|
||||||
|
**2. 更新的组件**
|
||||||
|
```
|
||||||
|
web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx
|
||||||
|
```
|
||||||
|
- 完全重写,使用WebSocket
|
||||||
|
- 实时连接状态显示
|
||||||
|
- 流式消息支持
|
||||||
|
- 自动重连
|
||||||
|
|
||||||
|
**3. HTTP客户端更新**
|
||||||
|
```
|
||||||
|
web/src/app/infra/http/BackendClient.ts
|
||||||
|
```
|
||||||
|
- 移除所有旧的WebChat API
|
||||||
|
- 仅保留WebSocket API
|
||||||
|
|
||||||
|
### 测试工具
|
||||||
|
|
||||||
|
**Python测试客户端**
|
||||||
|
```
|
||||||
|
test_websocket_client.py
|
||||||
|
```
|
||||||
|
- 单连接交互测试
|
||||||
|
- 多连接并发测试
|
||||||
|
- 命令行工具
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
|
||||||
|
**使用文档**
|
||||||
|
```
|
||||||
|
WEBSOCKET_README.md
|
||||||
|
```
|
||||||
|
- 完整的API文档
|
||||||
|
- 架构说明
|
||||||
|
- 使用示例
|
||||||
|
- 故障排查
|
||||||
|
|
||||||
|
## 核心变更
|
||||||
|
|
||||||
|
### 后端变更
|
||||||
|
|
||||||
|
**1. botmgr.py**
|
||||||
|
- ❌ 移除 `webchat_proxy_bot`
|
||||||
|
- ✅ 仅保留 `websocket_proxy_bot`
|
||||||
|
- ✅ 更新适配器过滤逻辑(排除`websocket`而非`webchat`)
|
||||||
|
|
||||||
|
**2. 适配器注册**
|
||||||
|
```python
|
||||||
|
# 旧代码(已删除)
|
||||||
|
webchat_adapter_class = self.adapter_dict['webchat']
|
||||||
|
self.webchat_proxy_bot = RuntimeBot(...)
|
||||||
|
|
||||||
|
# 新代码
|
||||||
|
websocket_adapter_class = self.adapter_dict['websocket']
|
||||||
|
self.websocket_proxy_bot = RuntimeBot(
|
||||||
|
uuid='websocket-proxy-bot',
|
||||||
|
name='WebSocket',
|
||||||
|
adapter='websocket',
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端变更
|
||||||
|
|
||||||
|
**1. API调用完全更换**
|
||||||
|
|
||||||
|
旧代码(已删除):
|
||||||
|
```typescript
|
||||||
|
// SSE流式请求
|
||||||
|
await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ is_stream: true })
|
||||||
|
})
|
||||||
|
// 手动解析 text/event-stream
|
||||||
|
```
|
||||||
|
|
||||||
|
新代码:
|
||||||
|
```typescript
|
||||||
|
// WebSocket实时通信
|
||||||
|
const wsClient = new WebSocketClient(pipelineId, sessionType);
|
||||||
|
await wsClient.connect();
|
||||||
|
|
||||||
|
wsClient.onMessage((message) => {
|
||||||
|
// 流式消息自动处理
|
||||||
|
setMessages(prev => [...prev, message]);
|
||||||
|
});
|
||||||
|
|
||||||
|
wsClient.sendMessage(messageChain);
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 连接状态管理**
|
||||||
|
|
||||||
|
新增功能:
|
||||||
|
- ✅ 实时连接状态指示器(绿色/红色圆点)
|
||||||
|
- ✅ 连接/断开toast提示
|
||||||
|
- ✅ 自动重连逻辑
|
||||||
|
- ✅ 心跳保活
|
||||||
|
|
||||||
|
**3. 流式支持**
|
||||||
|
|
||||||
|
完整的流式消息处理:
|
||||||
|
```typescript
|
||||||
|
wsClient.onMessage((message) => {
|
||||||
|
if (message.is_final) {
|
||||||
|
// 最终消息
|
||||||
|
finalizeBotMessage(message);
|
||||||
|
} else {
|
||||||
|
// 中间消息块,实时更新UI
|
||||||
|
updateBotMessage(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## API对比
|
||||||
|
|
||||||
|
### WebSocket端点
|
||||||
|
|
||||||
|
**连接**
|
||||||
|
```
|
||||||
|
ws://localhost:8000/api/v1/pipelines/<pipeline_uuid>/ws/connect?session_type=<person|group>
|
||||||
|
```
|
||||||
|
|
||||||
|
**消息格式**
|
||||||
|
|
||||||
|
客户端发送:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"message": [
|
||||||
|
{"type": "Plain", "text": "你好"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
服务器响应(流式):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "response",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "你好,我是...",
|
||||||
|
"is_final": false,
|
||||||
|
"timestamp": "2025-01-28T..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
| 端点 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/v1/pipelines/<uuid>/ws/messages/<type>` | GET | 获取消息历史 |
|
||||||
|
| `/api/v1/pipelines/<uuid>/ws/reset/<type>` | POST | 重置会话 |
|
||||||
|
| `/api/v1/pipelines/<uuid>/ws/connections` | GET | 获取连接统计 |
|
||||||
|
| `/api/v1/pipelines/<uuid>/ws/broadcast` | POST | 广播消息 |
|
||||||
|
|
||||||
|
## 流式支持详解
|
||||||
|
|
||||||
|
### 后端流式实现
|
||||||
|
|
||||||
|
**WebSocket Adapter**
|
||||||
|
```python
|
||||||
|
async def reply_message_chunk(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
bot_message,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
is_final: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""回复消息块 - 流式"""
|
||||||
|
message_data = WebSocketMessage(
|
||||||
|
id=-1,
|
||||||
|
role='assistant',
|
||||||
|
content=str(message),
|
||||||
|
message_chain=[component.__dict__ for component in message],
|
||||||
|
timestamp=datetime.now().isoformat(),
|
||||||
|
is_final=is_final and bot_message.tool_calls is None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送到队列,由WebSocket连接处理发送
|
||||||
|
await session.resp_queues[message_id].put(message_data)
|
||||||
|
return message_data.model_dump()
|
||||||
|
|
||||||
|
async def is_stream_output_supported(self) -> bool:
|
||||||
|
"""WebSocket始终支持流式输出"""
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端流式处理
|
||||||
|
|
||||||
|
**DebugDialog组件**
|
||||||
|
```typescript
|
||||||
|
wsClient.onMessage((message) => {
|
||||||
|
setMessages((prevMessages) => {
|
||||||
|
const existingIndex = prevMessages.findIndex(
|
||||||
|
(msg) => msg.role === 'assistant' && msg.content === 'Generating...'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
// 更新正在生成的消息
|
||||||
|
const updatedMessages = [...prevMessages];
|
||||||
|
updatedMessages[existingIndex] = message;
|
||||||
|
return updatedMessages;
|
||||||
|
} else {
|
||||||
|
// 添加新消息
|
||||||
|
return [...prevMessages, message];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 兼容性说明
|
||||||
|
|
||||||
|
### ⚠️ 不兼容旧版本
|
||||||
|
|
||||||
|
此次迁移**完全不兼容**旧的WebChat系统:
|
||||||
|
|
||||||
|
1. **API端点变更**
|
||||||
|
- 旧: `/api/v1/pipelines/<uuid>/chat/send`
|
||||||
|
- 新: `ws://.../<uuid>/ws/connect`
|
||||||
|
|
||||||
|
2. **通信协议变更**
|
||||||
|
- 旧: HTTP + SSE (Server-Sent Events)
|
||||||
|
- 新: WebSocket (双向)
|
||||||
|
|
||||||
|
3. **流式实现变更**
|
||||||
|
- 旧: `text/event-stream` 格式
|
||||||
|
- 新: WebSocket JSON消息
|
||||||
|
|
||||||
|
### 迁移要求
|
||||||
|
|
||||||
|
使用新系统需要:
|
||||||
|
1. ✅ 前端必须支持WebSocket
|
||||||
|
2. ✅ 后端必须运行新的WebSocket适配器
|
||||||
|
3. ✅ 清除旧的WebChat相关配置
|
||||||
|
|
||||||
|
## 优势对比
|
||||||
|
|
||||||
|
| 特性 | 旧WebChat (SSE) | 新WebSocket |
|
||||||
|
|------|----------------|-------------|
|
||||||
|
| 双向通信 | ❌ 单向(服务器→客户端) | ✅ 双向 |
|
||||||
|
| 主动推送 | ❌ 不支持 | ✅ 支持 |
|
||||||
|
| 连接管理 | ❌ 无状态 | ✅ 有状态,完整生命周期 |
|
||||||
|
| 流式输出 | ✅ 支持 | ✅ 支持(更优) |
|
||||||
|
| 心跳机制 | ❌ 无 | ✅ 30秒心跳 |
|
||||||
|
| 自动重连 | ❌ 无 | ✅ 最多5次 |
|
||||||
|
| 多连接 | ⚠️ 难以管理 | ✅ 完整支持 |
|
||||||
|
| 连接状态 | ❌ 不可见 | ✅ 实时显示 |
|
||||||
|
| 广播功能 | ❌ 不支持 | ✅ 支持 |
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
### 1. Python测试客户端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 单连接测试
|
||||||
|
python test_websocket_client.py <pipeline_uuid>
|
||||||
|
|
||||||
|
# 指定会话类型
|
||||||
|
python test_websocket_client.py <pipeline_uuid> --session-type group
|
||||||
|
|
||||||
|
# 多连接并发测试(5个连接)
|
||||||
|
python test_websocket_client.py <pipeline_uuid> --multi 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 前端测试
|
||||||
|
|
||||||
|
1. 启动LangBot服务器
|
||||||
|
2. 访问前端界面
|
||||||
|
3. 打开流水线调试对话框
|
||||||
|
4. 观察连接状态指示器(左下角圆点)
|
||||||
|
5. 发送消息测试流式响应
|
||||||
|
|
||||||
|
### 3. 浏览器控制台测试
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const ws = new WebSocket('ws://localhost:8000/api/v1/pipelines/<uuid>/ws/connect?session_type=person');
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('已连接');
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
message: [{type: 'Plain', text: '你好'}]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
console.log('收到:', JSON.parse(event.data));
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 为什么完全删除旧代码而不保留兼容性?
|
||||||
|
A: 根据需求,不需要考虑任何对老版本的兼容性,彻底迁移可以避免代码冗余和维护负担。
|
||||||
|
|
||||||
|
### Q: 流式输出如何工作?
|
||||||
|
A:
|
||||||
|
1. 后端通过`reply_message_chunk`发送消息块
|
||||||
|
2. 消息块放入队列
|
||||||
|
3. WebSocket连接从队列取出并发送
|
||||||
|
4. 前端实时更新UI
|
||||||
|
5. `is_final=true`表示最后一块
|
||||||
|
|
||||||
|
### Q: 如何确保连接不断开?
|
||||||
|
A:
|
||||||
|
1. 客户端每30秒发送心跳(ping)
|
||||||
|
2. 服务器响应pong
|
||||||
|
3. 连接断开时自动重连(最多5次)
|
||||||
|
|
||||||
|
### Q: 如何实现后端主动推送?
|
||||||
|
A:
|
||||||
|
1. 调用 `/api/v1/pipelines/<uuid>/ws/broadcast` API
|
||||||
|
2. 消息会被推送到该流水线的所有连接
|
||||||
|
3. 前端通过`onBroadcast`回调接收
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
✅ **完成的工作**
|
||||||
|
- 完全移除旧的WebChat/SSE系统
|
||||||
|
- 实现完整的WebSocket双向通信系统
|
||||||
|
- 支持流式输出
|
||||||
|
- 支持多连接并发
|
||||||
|
- 实现自动重连和心跳机制
|
||||||
|
- 提供完整的测试工具和文档
|
||||||
|
|
||||||
|
✅ **核心特性**
|
||||||
|
- 双向实时通信
|
||||||
|
- 流式消息支持
|
||||||
|
- 多连接管理
|
||||||
|
- 自动重连
|
||||||
|
- 心跳保活
|
||||||
|
- 连接状态可视化
|
||||||
|
- 广播消息
|
||||||
|
|
||||||
|
✅ **技术亮点**
|
||||||
|
- 异步架构(asyncio)
|
||||||
|
- 线程安全的连接管理
|
||||||
|
- 独立的消息队列
|
||||||
|
- 完整的错误处理
|
||||||
|
- 模块化设计
|
||||||
|
|
||||||
|
🎉 系统已完全迁移到WebSocket,无任何旧代码遗留!
|
||||||
259
docs/SEEKDB_INTEGRATION.md
Normal file
259
docs/SEEKDB_INTEGRATION.md
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
# SeekDB Vector Database Integration
|
||||||
|
|
||||||
|
This document describes how to use OceanBase SeekDB as the vector database backend for LangBot's knowledge base feature.
|
||||||
|
|
||||||
|
## What is SeekDB?
|
||||||
|
|
||||||
|
**OceanBase SeekDB** is an AI-native search database that unifies relational, vector, text, JSON and GIS in a single engine, enabling hybrid search and in-database AI workflows. It's developed by OceanBase and released under Apache 2.0 license.
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
- **Hybrid Search**: Combine vector search, full-text search and relational query in a single statement
|
||||||
|
- **Multi-Model Support**: Support relational, vector, text, JSON and GIS in a single engine
|
||||||
|
- **Lightweight**: Requires as little as 1 CPU core and 2 GB of memory
|
||||||
|
- **Multiple Deployment Modes**: Supports both embedded mode and client/server mode
|
||||||
|
- **MySQL Compatible**: Powered by OceanBase engine with full ACID compliance and MySQL compatibility
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
|
||||||
|
|
||||||
|
If you need to install it manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pyseekdb
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ Platform Compatibility
|
||||||
|
|
||||||
|
### Embedded Mode
|
||||||
|
|
||||||
|
| Platform | Status | Notes |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
||||||
|
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
||||||
|
| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
||||||
|
|
||||||
|
**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode.
|
||||||
|
|
||||||
|
### Server Mode (Docker)
|
||||||
|
|
||||||
|
| Platform | Status | Notes |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| Linux | ✅ Supported | Full Docker support |
|
||||||
|
| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
|
||||||
|
| Windows | ⚠️ Untested | Should work but not yet tested |
|
||||||
|
|
||||||
|
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
|
||||||
|
- Using ChromaDB or Qdrant as alternatives
|
||||||
|
- Connecting to a remote SeekDB server on Linux if available
|
||||||
|
|
||||||
|
### Server Mode (Remote Connection)
|
||||||
|
|
||||||
|
| Platform | Status | Notes |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
|
||||||
|
|
||||||
|
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Embedded Mode (Recommended for Development)
|
||||||
|
|
||||||
|
Embedded mode runs SeekDB directly within the LangBot process, storing data locally. This is the simplest setup and requires no external services.
|
||||||
|
|
||||||
|
Edit your `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
vdb:
|
||||||
|
use: seekdb
|
||||||
|
seekdb:
|
||||||
|
mode: embedded
|
||||||
|
path: './data/seekdb' # Path to store SeekDB data
|
||||||
|
database: 'langbot' # Database name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Mode (For Production)
|
||||||
|
|
||||||
|
Server mode connects to a remote SeekDB server or OceanBase server. This is recommended for production deployments.
|
||||||
|
|
||||||
|
#### SeekDB Server
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
vdb:
|
||||||
|
use: seekdb
|
||||||
|
seekdb:
|
||||||
|
mode: server
|
||||||
|
host: 'localhost'
|
||||||
|
port: 2881
|
||||||
|
database: 'langbot'
|
||||||
|
user: 'root'
|
||||||
|
password: '' # Can also use SEEKDB_PASSWORD env var
|
||||||
|
```
|
||||||
|
|
||||||
|
#### OceanBase Server
|
||||||
|
|
||||||
|
If you're using OceanBase with seekdb capabilities:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
vdb:
|
||||||
|
use: seekdb
|
||||||
|
seekdb:
|
||||||
|
mode: server
|
||||||
|
host: 'localhost'
|
||||||
|
port: 2881
|
||||||
|
tenant: 'sys' # OceanBase tenant name
|
||||||
|
database: 'langbot'
|
||||||
|
user: 'root'
|
||||||
|
password: ''
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Parameters
|
||||||
|
|
||||||
|
| Parameter | Required | Default | Description |
|
||||||
|
|-----------|----------|--------------|-------------|
|
||||||
|
| `mode` | No | `embedded` | Deployment mode: `embedded` or `server` |
|
||||||
|
| `path` | No | `./data/seekdb` | Data directory for embedded mode |
|
||||||
|
| `database` | No | `langbot` | Database name |
|
||||||
|
| `host` | No | `localhost` | Server host (server mode only) |
|
||||||
|
| `port` | No | `2881` | Server port (server mode only) |
|
||||||
|
| `user` | No | `root` | Username (server mode only) |
|
||||||
|
| `password` | No | `''` | Password (server mode only) |
|
||||||
|
| `tenant` | No | None | OceanBase tenant (optional, server mode only) |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Once configured, SeekDB will be used automatically for all knowledge base operations in LangBot:
|
||||||
|
|
||||||
|
1. **Creating Knowledge Bases**: Vectors will be stored in SeekDB collections
|
||||||
|
2. **Adding Documents**: Document embeddings will be indexed in SeekDB
|
||||||
|
3. **Searching**: Vector similarity search will use SeekDB's efficient indexing
|
||||||
|
4. **Deleting**: Document removal will delete vectors from SeekDB
|
||||||
|
|
||||||
|
No code changes are required - just update your configuration!
|
||||||
|
|
||||||
|
## Architecture Details
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
The SeekDB adapter is implemented in `src/langbot/pkg/vector/vdbs/seekdb.py` and follows the same `VectorDatabase` interface as Chroma and Qdrant adapters.
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `add_embeddings()`: Add vectors with metadata to a collection
|
||||||
|
- `search()`: Perform vector similarity search
|
||||||
|
- `delete_by_file_id()`: Delete vectors by file ID metadata
|
||||||
|
- `get_or_create_collection()`: Manage collections
|
||||||
|
- `delete_collection()`: Remove entire collections
|
||||||
|
|
||||||
|
### Vector Storage
|
||||||
|
|
||||||
|
- Collections are created with HNSW (Hierarchical Navigable Small World) index
|
||||||
|
- Default distance metric: Cosine similarity
|
||||||
|
- Default vector dimension: 384 (adjusts automatically based on embeddings)
|
||||||
|
- Metadata is stored alongside vectors for filtering
|
||||||
|
|
||||||
|
## Advantages Over Other Vector Databases
|
||||||
|
|
||||||
|
### vs. ChromaDB
|
||||||
|
- ✅ Better MySQL compatibility
|
||||||
|
- ✅ Hybrid search capabilities (vector + full-text + SQL)
|
||||||
|
- ✅ Production-grade distributed mode support
|
||||||
|
- ✅ Lightweight embedded mode
|
||||||
|
|
||||||
|
### vs. Qdrant
|
||||||
|
- ✅ SQL query support
|
||||||
|
- ✅ MySQL ecosystem integration
|
||||||
|
- ✅ Simpler deployment (no Docker required for embedded mode)
|
||||||
|
- ✅ Multi-model data support (not just vectors)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Import Error
|
||||||
|
|
||||||
|
If you see: `ImportError: pyseekdb is not installed`
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
```bash
|
||||||
|
pip install pyseekdb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Embedded Mode Error on macOS/Windows
|
||||||
|
|
||||||
|
**Error**:
|
||||||
|
```
|
||||||
|
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
|
||||||
|
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause**: `pylibseekdb` is only available on Linux platforms.
|
||||||
|
|
||||||
|
**Solution**: Use server mode instead:
|
||||||
|
1. Deploy SeekDB on a Linux server or VM
|
||||||
|
2. Configure LangBot to use server mode:
|
||||||
|
```yaml
|
||||||
|
vdb:
|
||||||
|
use: seekdb
|
||||||
|
seekdb:
|
||||||
|
mode: server
|
||||||
|
host: 'your-seekdb-server-ip'
|
||||||
|
port: 2881
|
||||||
|
database: 'langbot'
|
||||||
|
user: 'root'
|
||||||
|
password: ''
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative**: Use ChromaDB or Qdrant, which work on all platforms:
|
||||||
|
```yaml
|
||||||
|
vdb:
|
||||||
|
use: chroma # or qdrant
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Container Fails on macOS
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
```bash
|
||||||
|
docker run -d -p 2881:2881 oceanbase/seekdb:latest
|
||||||
|
# Container exits immediately with code 30
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error in logs**:
|
||||||
|
```
|
||||||
|
[ERROR] Code: Agent.SeekDB.Not.Exists
|
||||||
|
Message: initialize failed: init agent failed: SeekDB not exists in current directory.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
|
||||||
|
|
||||||
|
**Status**: Under investigation by OceanBase team.
|
||||||
|
|
||||||
|
**Workaround Options**:
|
||||||
|
1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
|
||||||
|
2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
|
||||||
|
3. **Wait for fix**: Monitor the GitHub issue for updates
|
||||||
|
|
||||||
|
### Connection Error (Server Mode)
|
||||||
|
|
||||||
|
If SeekDB server is not reachable, check:
|
||||||
|
1. Server is running: `ps aux | grep observer`
|
||||||
|
2. Port is accessible: `nc -zv localhost 2881`
|
||||||
|
3. Credentials are correct in config
|
||||||
|
4. Firewall allows connections on port 2881
|
||||||
|
|
||||||
|
### Performance Issues
|
||||||
|
|
||||||
|
For large datasets:
|
||||||
|
- Use server mode instead of embedded mode
|
||||||
|
- Ensure adequate memory allocation
|
||||||
|
- Consider using OceanBase distributed mode for very large scale
|
||||||
|
- Adjust HNSW index parameters if needed
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- SeekDB GitHub: https://github.com/oceanbase/seekdb
|
||||||
|
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
|
||||||
|
- OceanBase Documentation: https://oceanbase.ai
|
||||||
|
- LangBot Documentation: https://docs.langbot.app
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
SeekDB is licensed under Apache License 2.0.
|
||||||
394
docs/WEBSOCKET_README.md
Normal file
394
docs/WEBSOCKET_README.md
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
# LangBot WebSocket 双向通信系统
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
这是一个内置在 LangBot 中的完整 IM (即时通讯) 系统,支持:
|
||||||
|
|
||||||
|
- ✅ WebSocket 双向实时通信
|
||||||
|
- ✅ 多个客户端并发连接
|
||||||
|
- ✅ 前端到后端的消息发送
|
||||||
|
- ✅ 后端到前端的主动推送
|
||||||
|
- ✅ 流式响应支持
|
||||||
|
- ✅ 连接管理和会话隔离
|
||||||
|
- ✅ 心跳机制
|
||||||
|
- ✅ 广播消息功能
|
||||||
|
|
||||||
|
## 架构设计
|
||||||
|
|
||||||
|
### 核心组件
|
||||||
|
|
||||||
|
1. **WebSocketConnectionManager** (`websocket_manager.py`)
|
||||||
|
- 管理所有活跃的 WebSocket 连接
|
||||||
|
- 支持按流水线、会话类型查询连接
|
||||||
|
- 提供广播和单播功能
|
||||||
|
- 线程安全的并发访问控制
|
||||||
|
|
||||||
|
2. **WebSocketAdapter** (`websocket_adapter.py`)
|
||||||
|
- 实现平台适配器接口
|
||||||
|
- 处理消息的接收和发送
|
||||||
|
- 支持流式输出
|
||||||
|
- 管理消息历史
|
||||||
|
|
||||||
|
3. **WebSocketChatRouterGroup** (`websocket_chat.py`)
|
||||||
|
- WebSocket 路由控制器
|
||||||
|
- 处理连接建立、消息收发
|
||||||
|
- 实现心跳机制
|
||||||
|
- 提供 REST API 接口
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### WebSocket 连接
|
||||||
|
|
||||||
|
#### 建立连接
|
||||||
|
|
||||||
|
```
|
||||||
|
ws://localhost:8000/api/v1/pipelines/<pipeline_uuid>/ws/connect?session_type=<person|group>
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `pipeline_uuid`: 流水线 UUID (必需)
|
||||||
|
- `session_type`: 会话类型,可选 `person` 或 `group` (默认: `person`)
|
||||||
|
|
||||||
|
**连接成功响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "connected",
|
||||||
|
"connection_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"pipeline_uuid": "your-pipeline-uuid",
|
||||||
|
"session_type": "person",
|
||||||
|
"timestamp": "2025-01-28T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 消息格式
|
||||||
|
|
||||||
|
#### 客户端发送消息
|
||||||
|
|
||||||
|
**发送聊天消息:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"message": [
|
||||||
|
{
|
||||||
|
"type": "Plain",
|
||||||
|
"text": "你好,这是一条测试消息"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**发送心跳:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ping"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**主动断开连接:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "disconnect"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 服务器响应消息
|
||||||
|
|
||||||
|
**聊天响应 (流式):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "response",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "这是机器人的回复",
|
||||||
|
"message_chain": [...],
|
||||||
|
"timestamp": "2025-01-28T12:00:00",
|
||||||
|
"is_final": false,
|
||||||
|
"connection_id": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**心跳响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "pong",
|
||||||
|
"timestamp": "2025-01-28T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**广播消息:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "broadcast",
|
||||||
|
"message": "这是一条广播消息",
|
||||||
|
"timestamp": "2025-01-28T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误消息:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"message": "错误描述"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### REST API 接口
|
||||||
|
|
||||||
|
#### 1. 获取消息历史
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/pipelines/<pipeline_uuid>/ws/messages/<session_type>
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"messages": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 重置会话
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/pipelines/<pipeline_uuid>/ws/reset/<session_type>
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"message": "Session reset successfully"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 获取连接统计
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/pipelines/<pipeline_uuid>/ws/connections
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"stats": {
|
||||||
|
"total_connections": 5,
|
||||||
|
"pipelines": 2,
|
||||||
|
"connections_by_pipeline": {
|
||||||
|
"pipeline-1": 3,
|
||||||
|
"pipeline-2": 2
|
||||||
|
},
|
||||||
|
"connections_by_session_type": {
|
||||||
|
"person": 4,
|
||||||
|
"group": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"connection_id": "...",
|
||||||
|
"session_type": "person",
|
||||||
|
"created_at": "2025-01-28T12:00:00",
|
||||||
|
"last_active": "2025-01-28T12:05:00",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 广播消息 (后端主动推送)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/pipelines/<pipeline_uuid>/ws/broadcast
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"message": "这是一条广播消息"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"message": "Broadcast sent successfully"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### Python 客户端示例
|
||||||
|
|
||||||
|
使用提供的测试客户端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
pip install websockets
|
||||||
|
|
||||||
|
# 单个连接测试
|
||||||
|
python test_websocket_client.py <pipeline_uuid>
|
||||||
|
|
||||||
|
# 指定会话类型
|
||||||
|
python test_websocket_client.py <pipeline_uuid> --session-type group
|
||||||
|
|
||||||
|
# 多连接并发测试
|
||||||
|
python test_websocket_client.py <pipeline_uuid> --multi 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript 客户端示例
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 建立 WebSocket 连接
|
||||||
|
const ws = new WebSocket('ws://localhost:8000/api/v1/pipelines/your-pipeline-uuid/ws/connect?session_type=person');
|
||||||
|
|
||||||
|
// 连接建立
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('WebSocket 连接已建立');
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
message: [
|
||||||
|
{
|
||||||
|
type: 'Plain',
|
||||||
|
text: '你好'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 接收消息
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type === 'connected') {
|
||||||
|
console.log('连接成功:', data.connection_id);
|
||||||
|
} else if (data.type === 'response') {
|
||||||
|
console.log('机器人回复:', data.data.content);
|
||||||
|
if (data.data.is_final) {
|
||||||
|
console.log('响应完成');
|
||||||
|
}
|
||||||
|
} else if (data.type === 'broadcast') {
|
||||||
|
console.log('收到广播:', data.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 连接关闭
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket 连接已关闭');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 错误处理
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket 错误:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送心跳
|
||||||
|
setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'ping' }));
|
||||||
|
}
|
||||||
|
}, 30000); // 每 30 秒发送一次心跳
|
||||||
|
```
|
||||||
|
|
||||||
|
## 特性说明
|
||||||
|
|
||||||
|
### 1. 多连接支持
|
||||||
|
|
||||||
|
系统支持同时建立多个 WebSocket 连接,每个连接都有唯一的 `connection_id`。连接按照流水线和会话类型进行分组管理。
|
||||||
|
|
||||||
|
### 2. 双向通信
|
||||||
|
|
||||||
|
- **前端 → 后端**: 客户端可以主动发送消息给服务器
|
||||||
|
- **后端 → 前端**: 服务器可以通过广播 API 主动推送消息给客户端
|
||||||
|
|
||||||
|
### 3. 流式响应
|
||||||
|
|
||||||
|
支持流式输出,机器人的响应会分块发送,客户端可以实时显示部分响应内容。
|
||||||
|
|
||||||
|
### 4. 会话隔离
|
||||||
|
|
||||||
|
支持 `person` 和 `group` 两种会话类型,不同类型的会话消息历史互不影响。
|
||||||
|
|
||||||
|
### 5. 连接管理
|
||||||
|
|
||||||
|
- 自动追踪连接状态
|
||||||
|
- 记录最后活跃时间
|
||||||
|
- 支持连接统计查询
|
||||||
|
- 连接断开时自动清理资源
|
||||||
|
|
||||||
|
### 6. 心跳机制
|
||||||
|
|
||||||
|
客户端可以定期发送 `ping` 消息,服务器会响应 `pong`,用于保持连接活跃和检测连接状态。
|
||||||
|
|
||||||
|
## 架构优势
|
||||||
|
|
||||||
|
1. **高并发**: 使用 asyncio 异步架构,支持大量并发连接
|
||||||
|
2. **可扩展**: 模块化设计,易于扩展新功能
|
||||||
|
3. **线程安全**: 连接管理器使用锁机制保证并发安全
|
||||||
|
4. **消息队列**: 每个连接独立的发送队列,避免消息混乱
|
||||||
|
5. **灵活路由**: 支持按流水线、会话类型灵活路由消息
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **认证**: 当前 WebSocket 连接不需要认证,生产环境建议添加认证机制
|
||||||
|
2. **心跳**: 建议客户端实现心跳机制,避免连接超时
|
||||||
|
3. **重连**: 客户端应实现断线重连逻辑
|
||||||
|
4. **消息大小**: 注意控制单条消息大小,避免内存溢出
|
||||||
|
5. **连接数限制**: 生产环境建议设置最大连接数限制
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 连接失败
|
||||||
|
|
||||||
|
1. 检查流水线 UUID 是否正确
|
||||||
|
2. 检查服务器是否正常运行
|
||||||
|
3. 检查防火墙设置
|
||||||
|
|
||||||
|
### 消息发送失败
|
||||||
|
|
||||||
|
1. 检查消息格式是否正确
|
||||||
|
2. 检查连接是否仍然活跃
|
||||||
|
3. 查看服务器日志获取详细错误信息
|
||||||
|
|
||||||
|
### 性能问题
|
||||||
|
|
||||||
|
1. 检查并发连接数是否过多
|
||||||
|
2. 检查消息处理速度
|
||||||
|
3. 考虑使用连接池或负载均衡
|
||||||
|
|
||||||
|
## 开发调试
|
||||||
|
|
||||||
|
启用详细日志:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
logging.getLogger('langbot.pkg.platform.sources.websocket_adapter').setLevel(logging.DEBUG)
|
||||||
|
logging.getLogger('langbot.pkg.platform.sources.websocket_manager').setLevel(logging.DEBUG)
|
||||||
|
logging.getLogger('langbot.pkg.api.http.controller.groups.pipelines.websocket_chat').setLevel(logging.DEBUG)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 后续改进建议
|
||||||
|
|
||||||
|
1. 添加用户认证和授权机制
|
||||||
|
2. 实现消息持久化
|
||||||
|
3. 添加消息加密
|
||||||
|
4. 实现更丰富的消息类型 (图片、文件等)
|
||||||
|
5. 添加消息已读/未读状态
|
||||||
|
6. 实现群组聊天功能
|
||||||
|
7. 添加在线状态显示
|
||||||
|
8. 实现消息撤回功能
|
||||||
197
docs/event-based-agents/00-overview.md
Normal file
197
docs/event-based-agents/00-overview.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# Event Based Agents 架构设计总览
|
||||||
|
|
||||||
|
## 1. 背景与动机
|
||||||
|
|
||||||
|
### 当前架构的局限性
|
||||||
|
|
||||||
|
LangBot 当前的平台适配器架构围绕**消息事件**单一场景设计:
|
||||||
|
|
||||||
|
- **事件层面**:只监听 `FriendMessage`(私聊消息)和 `GroupMessage`(群消息)两种事件
|
||||||
|
- **API 层面**:只暴露 `send_message` 和 `reply_message` 两个平台 API
|
||||||
|
- **处理层面**:所有消息统一进入 Pipeline 流水线处理,无法为不同事件类型配置不同处理逻辑
|
||||||
|
- **适配器结构**:每个适配器是单个 Python 文件(200-800 行),随着功能增加难以维护
|
||||||
|
|
||||||
|
这导致以下问题:
|
||||||
|
|
||||||
|
1. **无法处理非消息事件**:新成员入群、好友请求、消息撤回、消息编辑等大部分平台都支持的事件被完全忽略
|
||||||
|
2. **平台能力未充分利用**:编辑消息、撤回消息、获取群成员列表、管理群组等 API 无法使用
|
||||||
|
3. **插件能力受限**:插件只能监听消息事件、只能发送/回复消息,无法实现更丰富的交互
|
||||||
|
4. **处理逻辑不灵活**:所有消息走同一条 Pipeline,无法为入群欢迎、好友自动通过等场景配置独立的处理流程
|
||||||
|
|
||||||
|
### 设计目标
|
||||||
|
|
||||||
|
Event Based Agents(EBA)架构旨在将 LangBot 从"消息处理平台"升级为"事件驱动的智能代理平台":
|
||||||
|
|
||||||
|
- **丰富事件**:支持消息、群组、好友、Bot 状态等多种事件类型
|
||||||
|
- **丰富 API**:支持消息编辑/撤回、群组管理、用户信息查询等通用 API,以及适配器特有 API 的透传调用
|
||||||
|
- **灵活编排**:用户可在 WebUI 上为每个 Bot 的每种事件类型配置不同的处理器
|
||||||
|
- **可扩展**:适配器可声明自己支持的事件和 API,平台特有能力通过标准机制暴露
|
||||||
|
- **向后兼容**:现有插件无需修改即可在新架构下运行
|
||||||
|
|
||||||
|
## 2. 架构对比
|
||||||
|
|
||||||
|
### 现有架构
|
||||||
|
|
||||||
|
```
|
||||||
|
消息平台 (Telegram/Discord/...)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
平台适配器 (单文件, 只处理消息)
|
||||||
|
│ FriendMessage / GroupMessage
|
||||||
|
▼
|
||||||
|
RuntimeBot (注册 on_friend_message / on_group_message 回调)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
MessageAggregator (消息聚合)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
QueryPool → Controller → Pipeline (固定阶段链)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ RequestRunner (local-agent / dify / n8n / ...)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
adapter.reply_message() / adapter.send_message()
|
||||||
|
```
|
||||||
|
|
||||||
|
关键代码路径:
|
||||||
|
- 适配器基类:`langbot-plugin-sdk/.../abstract/platform/adapter.py` — `AbstractMessagePlatformAdapter`
|
||||||
|
- 事件定义:`langbot-plugin-sdk/.../builtin/platform/events.py` — 仅 `FriendMessage` / `GroupMessage`
|
||||||
|
- Bot 管理:`LangBot/src/langbot/pkg/platform/botmgr.py` — `RuntimeBot` 只注册两个消息回调
|
||||||
|
- 流水线控制:`LangBot/src/langbot/pkg/pipeline/controller.py` — 从 QueryPool 消费并执行 Pipeline
|
||||||
|
|
||||||
|
### 新架构(Event Based Agents)
|
||||||
|
|
||||||
|
```
|
||||||
|
消息平台 (Telegram/Discord/...)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
平台适配器 (独立目录, 监听所有事件, 实现丰富 API)
|
||||||
|
│ MessageReceived / MemberJoined / FriendRequest / ...
|
||||||
|
▼
|
||||||
|
EventBus (统一事件总线)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
EventRouter (事件路由引擎, 读取 Bot 的 event_handlers 配置)
|
||||||
|
│
|
||||||
|
├─→ PipelineHandler — 现有流水线(完整 Stage 链)
|
||||||
|
├─→ AgentHandler — 直接调用 RequestRunner(轻量 AI 处理)
|
||||||
|
├─→ WebhookHandler — POST 到外部服务(Dify/n8n webhook 等)
|
||||||
|
└─→ PluginHandler — 分发给插件 EventListener
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
统一平台 API
|
||||||
|
send / reply / edit / delete / getGroupInfo / getUserInfo / callPlatformApi / ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 核心概念
|
||||||
|
|
||||||
|
### 3.1 统一事件体系
|
||||||
|
|
||||||
|
所有平台事件统一为命名空间式的事件类型:
|
||||||
|
|
||||||
|
| 命名空间 | 事件 | 说明 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `message.*` | `message.received`, `message.edited`, `message.deleted`, `message.reaction` | 消息相关 |
|
||||||
|
| `feedback.*` | `feedback.received` | 用户对 Bot 回复的点赞、点踩、取消反馈等评价事件 |
|
||||||
|
| `group.*` | `group.member_joined`, `group.member_left`, `group.member_banned`, `group.info_updated` | 群组相关 |
|
||||||
|
| `friend.*` | `friend.request_received`, `friend.added`, `friend.removed` | 好友相关 |
|
||||||
|
| `bot.*` | `bot.invited_to_group`, `bot.removed_from_group`, `bot.muted`, `bot.unmuted` | Bot 状态 |
|
||||||
|
| `platform.*` | `platform.{adapter}.{action}` | 适配器特有事件 |
|
||||||
|
|
||||||
|
详见 [01-event-system.md](./01-event-system.md)。
|
||||||
|
|
||||||
|
### 3.2 统一平台 API
|
||||||
|
|
||||||
|
扩展适配器基类,提供通用 API + 透传机制:
|
||||||
|
|
||||||
|
| 类别 | API | 必需/可选 |
|
||||||
|
|------|-----|----------|
|
||||||
|
| 消息 | `send_message`, `reply_message`, `edit_message`, `delete_message`, `forward_message` | send/reply 必需,其余可选 |
|
||||||
|
| 群组 | `get_group_info`, `get_group_member_list`, `get_group_member_info`, `mute_member`, `kick_member` | 全部可选 |
|
||||||
|
| 用户 | `get_user_info`, `get_friend_list` | 全部可选 |
|
||||||
|
| 媒体 | `upload_file`, `get_file_url` | 全部可选 |
|
||||||
|
| 透传 | `call_platform_api(action, params)` | 可选 |
|
||||||
|
|
||||||
|
详见 [02-platform-api.md](./02-platform-api.md)。
|
||||||
|
|
||||||
|
### 3.3 适配器新结构
|
||||||
|
|
||||||
|
每个适配器从单文件迁移到独立目录:
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/platform/adapters/
|
||||||
|
├── _base/ # 基类和通用定义
|
||||||
|
│ ├── adapter.py
|
||||||
|
│ ├── events.py
|
||||||
|
│ ├── entities.py
|
||||||
|
│ └── api.py
|
||||||
|
├── telegram/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── adapter.py # 主适配器类
|
||||||
|
│ ├── event_converter.py # 事件转换(多种事件类型)
|
||||||
|
│ ├── message_converter.py # 消息链转换
|
||||||
|
│ ├── api_impl.py # 通用 API 实现
|
||||||
|
│ ├── platform_api.py # 平台特有 API
|
||||||
|
│ ├── types.py # 平台特有类型
|
||||||
|
│ └── manifest.yaml
|
||||||
|
├── discord/
|
||||||
|
│ └── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
详见 [03-adapter-structure.md](./03-adapter-structure.md)。
|
||||||
|
|
||||||
|
### 3.4 事件处理器(Event Handler)
|
||||||
|
|
||||||
|
四种处理器类型,用户在 WebUI 的 Bot 管理页面配置:
|
||||||
|
|
||||||
|
| 类型 | 说明 | 适用场景 |
|
||||||
|
|------|------|----------|
|
||||||
|
| **pipeline** | 现有流水线机制,完整的多 Stage 处理链(PreProcessor → MessageProcessor → PostProcessor 等) | 复杂消息处理,需要完整的预处理/后处理流程 |
|
||||||
|
| **agent** | 直接调用 RequestRunner(local-agent / dify / n8n / coze / dashscope / langflow / tbox),从 Pipeline 中解耦 | 轻量级 AI 处理、直接对接外部 LLMOps 平台处理各类事件 |
|
||||||
|
| **webhook** | 将事件 POST 到外部 URL,根据响应执行动作 | 对接自建服务、Dify/n8n 的 Webhook 触发器、自定义后端 |
|
||||||
|
| **plugin** | 分发给插件 EventListener 处理 | 插件自定义逻辑 |
|
||||||
|
|
||||||
|
配置存储在 Bot 表的 `event_handlers` JSON 字段中,通过 WebUI 编排面板管理。
|
||||||
|
|
||||||
|
详见 [04-event-routing.md](./04-event-routing.md)。
|
||||||
|
|
||||||
|
### 3.5 插件 SDK 改造
|
||||||
|
|
||||||
|
- 新事件类型全部暴露给插件
|
||||||
|
- 新 API 全部通过 `LangBotAPIProxy` 暴露
|
||||||
|
- 兼容层保证现有插件零修改运行
|
||||||
|
|
||||||
|
详见 [05-plugin-sdk.md](./05-plugin-sdk.md)。
|
||||||
|
|
||||||
|
## 4. 关键设计决策
|
||||||
|
|
||||||
|
| # | 决策点 | 选择 | 理由 |
|
||||||
|
|---|--------|------|------|
|
||||||
|
| 1 | 事件处理器配置粒度 | 每个 Bot 独立配置 | Bot 是用户操作的核心单元,不同 Bot 可能对接不同业务场景 |
|
||||||
|
| 2 | 适配器特有 API | 统一抽象 + `call_platform_api` 透传 | 通用 API 覆盖大部分场景,透传机制保证灵活性,避免每个适配器导出独立的类型化 API 包 |
|
||||||
|
| 3 | 向后兼容策略 | 兼容层适配 | 保留旧事件类型和 API 作为新系统的 alias/wrapper,现有插件无需修改 |
|
||||||
|
| 4 | 处理器配置存储 | Bot 表新增 `event_handlers` JSON 字段 | 简单直接,避免新增关联表;替代现有 `use_pipeline_uuid` |
|
||||||
|
| 5 | Agent 处理器定位 | 从 Pipeline 中解耦 RequestRunner | 不是所有事件都需要完整 Pipeline Stage 链;Agent 处理器提供轻量级 AI 处理路径,支持所有现有 Runner |
|
||||||
|
| 6 | 事件命名方式 | 命名空间式(`message.received`) | 清晰的分类层级,便于通配匹配(`message.*`),与 WebUI 配置天然对应 |
|
||||||
|
|
||||||
|
## 5. 文档索引
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| [01-event-system.md](./01-event-system.md) | 统一事件体系:事件分类、定义、生命周期 |
|
||||||
|
| [02-platform-api.md](./02-platform-api.md) | 统一平台 API:通用 API、透传 API、实体定义 |
|
||||||
|
| [03-adapter-structure.md](./03-adapter-structure.md) | 适配器新结构:目录布局、基类、注册机制 |
|
||||||
|
| [04-event-routing.md](./04-event-routing.md) | 事件路由与编排:路由引擎、处理器类型、WebUI 数据模型 |
|
||||||
|
| [05-plugin-sdk.md](./05-plugin-sdk.md) | 插件 SDK 改造:新事件/API、兼容层 |
|
||||||
|
| [06-migration-plan.md](./06-migration-plan.md) | 分阶段迁移计划 |
|
||||||
|
|
||||||
|
## 6. 涉及的代码仓库
|
||||||
|
|
||||||
|
| 仓库 | 改动范围 |
|
||||||
|
|------|----------|
|
||||||
|
| **langbot-plugin-sdk** | 事件定义、实体模型、API 接口、适配器基类、通信协议扩展 |
|
||||||
|
| **LangBot**(后端) | 适配器实现、事件路由引擎、Bot 实体扩展、数据库迁移、RequestRunner 解耦 |
|
||||||
|
| **LangBot**(前端) | Bot 事件处理器编排面板 |
|
||||||
|
| **langbot-wiki** | 新架构文档、插件开发指南更新、适配器开发指南 |
|
||||||
|
| **langbot-plugin-demo** | 示例更新(使用新事件和 API) |
|
||||||
561
docs/event-based-agents/01-event-system.md
Normal file
561
docs/event-based-agents/01-event-system.md
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
# 统一事件体系
|
||||||
|
|
||||||
|
## 1. 设计原则
|
||||||
|
|
||||||
|
- **命名空间分类**:事件类型采用 `{namespace}.{action}` 格式,如 `message.received`
|
||||||
|
- **通用优先**:大部分平台都支持的事件抽象为通用事件,定义统一的字段格式
|
||||||
|
- **平台特有事件标准化**:各适配器的独有事件通过 `PlatformSpecificEvent` 承载,保留原始数据
|
||||||
|
- **向后兼容**:现有 `FriendMessage` / `GroupMessage` 通过兼容层映射到新的 `message.received` 事件
|
||||||
|
|
||||||
|
## 2. 事件基类层次
|
||||||
|
|
||||||
|
```
|
||||||
|
Event (事件基类)
|
||||||
|
├── MessageEvent (消息相关事件)
|
||||||
|
│ ├── MessageReceivedEvent # message.received
|
||||||
|
│ ├── MessageEditedEvent # message.edited
|
||||||
|
│ ├── MessageDeletedEvent # message.deleted
|
||||||
|
│ └── MessageReactionEvent # message.reaction
|
||||||
|
├── FeedbackEvent (用户反馈事件)
|
||||||
|
│ └── FeedbackReceivedEvent # feedback.received
|
||||||
|
├── GroupEvent (群组相关事件)
|
||||||
|
│ ├── MemberJoinedEvent # group.member_joined
|
||||||
|
│ ├── MemberLeftEvent # group.member_left
|
||||||
|
│ ├── MemberBannedEvent # group.member_banned
|
||||||
|
│ ├── MemberUnbannedEvent # group.member_unbanned
|
||||||
|
│ └── GroupInfoUpdatedEvent # group.info_updated
|
||||||
|
├── FriendEvent (好友相关事件)
|
||||||
|
│ ├── FriendRequestReceivedEvent # friend.request_received
|
||||||
|
│ ├── FriendAddedEvent # friend.added
|
||||||
|
│ └── FriendRemovedEvent # friend.removed
|
||||||
|
├── BotEvent (Bot 状态事件)
|
||||||
|
│ ├── BotInvitedToGroupEvent # bot.invited_to_group
|
||||||
|
│ ├── BotRemovedFromGroupEvent # bot.removed_from_group
|
||||||
|
│ ├── BotMutedEvent # bot.muted
|
||||||
|
│ └── BotUnmutedEvent # bot.unmuted
|
||||||
|
└── PlatformSpecificEvent # platform.{adapter}.{action}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 通用事件定义
|
||||||
|
|
||||||
|
### 3.1 事件基类
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Event(pydantic.BaseModel):
|
||||||
|
"""事件基类"""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
"""事件类型标识,如 'message.received'"""
|
||||||
|
|
||||||
|
timestamp: float
|
||||||
|
"""事件发生的时间戳"""
|
||||||
|
|
||||||
|
bot_uuid: str
|
||||||
|
"""接收到此事件的 Bot UUID"""
|
||||||
|
|
||||||
|
adapter_name: str
|
||||||
|
"""产生此事件的适配器名称"""
|
||||||
|
|
||||||
|
source_platform_object: typing.Optional[typing.Any] = None
|
||||||
|
"""原始平台事件对象,供适配器内部使用"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 消息事件
|
||||||
|
|
||||||
|
#### MessageReceivedEvent (`message.received`)
|
||||||
|
|
||||||
|
收到新消息。这是最核心的事件,替代现有的 `FriendMessage` / `GroupMessage`。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MessageReceivedEvent(Event):
|
||||||
|
"""收到新消息"""
|
||||||
|
|
||||||
|
type: str = "message.received"
|
||||||
|
|
||||||
|
message_id: typing.Union[int, str]
|
||||||
|
"""消息 ID"""
|
||||||
|
|
||||||
|
message_chain: MessageChain
|
||||||
|
"""消息内容"""
|
||||||
|
|
||||||
|
sender: User
|
||||||
|
"""发送者"""
|
||||||
|
|
||||||
|
chat_type: ChatType # "private" | "group"
|
||||||
|
"""会话类型"""
|
||||||
|
|
||||||
|
chat_id: typing.Union[int, str]
|
||||||
|
"""会话 ID(私聊为对方用户 ID,群聊为群 ID)"""
|
||||||
|
|
||||||
|
group: typing.Optional[Group] = None
|
||||||
|
"""群信息(仅群聊时存在)"""
|
||||||
|
```
|
||||||
|
|
||||||
|
与现有类型的映射关系:
|
||||||
|
- `chat_type == "private"` → 等价于现有 `FriendMessage`
|
||||||
|
- `chat_type == "group"` → 等价于现有 `GroupMessage`
|
||||||
|
|
||||||
|
`ChatType` 枚举:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ChatType(str, Enum):
|
||||||
|
PRIVATE = "private"
|
||||||
|
GROUP = "group"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MessageEditedEvent (`message.edited`)
|
||||||
|
|
||||||
|
消息被编辑。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MessageEditedEvent(Event):
|
||||||
|
"""消息被编辑"""
|
||||||
|
|
||||||
|
type: str = "message.edited"
|
||||||
|
|
||||||
|
message_id: typing.Union[int, str]
|
||||||
|
"""被编辑的消息 ID"""
|
||||||
|
|
||||||
|
new_content: MessageChain
|
||||||
|
"""编辑后的新内容"""
|
||||||
|
|
||||||
|
editor: User
|
||||||
|
"""编辑者"""
|
||||||
|
|
||||||
|
chat_type: ChatType
|
||||||
|
chat_id: typing.Union[int, str]
|
||||||
|
group: typing.Optional[Group] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MessageDeletedEvent (`message.deleted`)
|
||||||
|
|
||||||
|
消息被删除/撤回。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MessageDeletedEvent(Event):
|
||||||
|
"""消息被删除/撤回"""
|
||||||
|
|
||||||
|
type: str = "message.deleted"
|
||||||
|
|
||||||
|
message_id: typing.Union[int, str]
|
||||||
|
"""被删除的消息 ID"""
|
||||||
|
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
"""操作者(可能是发送者自己撤回,也可能是管理员删除)"""
|
||||||
|
|
||||||
|
chat_type: ChatType
|
||||||
|
chat_id: typing.Union[int, str]
|
||||||
|
group: typing.Optional[Group] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MessageReactionEvent (`message.reaction`)
|
||||||
|
|
||||||
|
消息收到表情回应。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MessageReactionEvent(Event):
|
||||||
|
"""消息收到表情回应"""
|
||||||
|
|
||||||
|
type: str = "message.reaction"
|
||||||
|
|
||||||
|
message_id: typing.Union[int, str]
|
||||||
|
"""被回应的消息 ID"""
|
||||||
|
|
||||||
|
user: User
|
||||||
|
"""回应者"""
|
||||||
|
|
||||||
|
reaction: str
|
||||||
|
"""回应的表情标识(emoji 或平台特定表情 ID)"""
|
||||||
|
|
||||||
|
is_add: bool
|
||||||
|
"""True 为添加回应,False 为移除回应"""
|
||||||
|
|
||||||
|
chat_type: ChatType
|
||||||
|
chat_id: typing.Union[int, str]
|
||||||
|
group: typing.Optional[Group] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 用户反馈事件
|
||||||
|
|
||||||
|
#### FeedbackReceivedEvent (`feedback.received`)
|
||||||
|
|
||||||
|
用户对 Bot 回复提交反馈。该事件用于承载平台提供的点赞、点踩、取消反馈以及点踩原因等评价信息;典型来源包括企业微信 AI Bot 的 `feedback_event`、飞书卡片按钮回调、Web Embed 的反馈入口等。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FeedbackReceivedEvent(Event):
|
||||||
|
"""收到用户反馈"""
|
||||||
|
|
||||||
|
type: str = "feedback.received"
|
||||||
|
|
||||||
|
feedback_id: str
|
||||||
|
"""平台侧反馈 ID,用于幂等记录或取消反馈"""
|
||||||
|
|
||||||
|
feedback_type: int
|
||||||
|
"""1 = like, 2 = dislike, 3 = cancel/remove feedback"""
|
||||||
|
|
||||||
|
feedback_content: typing.Optional[str] = None
|
||||||
|
"""用户填写的自由文本反馈"""
|
||||||
|
|
||||||
|
inaccurate_reasons: typing.Optional[list[str]] = None
|
||||||
|
"""点踩时平台提供的预设不准确原因"""
|
||||||
|
|
||||||
|
user_id: typing.Optional[str] = None
|
||||||
|
"""提交反馈的用户 ID"""
|
||||||
|
|
||||||
|
session_id: typing.Optional[str] = None
|
||||||
|
"""会话 ID,例如 person_xxx 或 group_xxx"""
|
||||||
|
|
||||||
|
message_id: typing.Optional[str] = None
|
||||||
|
"""被评价的 Bot 回复消息 ID"""
|
||||||
|
|
||||||
|
stream_id: typing.Optional[str] = None
|
||||||
|
"""流式回复 ID,用于关联 streaming response"""
|
||||||
|
```
|
||||||
|
|
||||||
|
设计约定:
|
||||||
|
|
||||||
|
- `feedback_id` 是幂等键;同一个 `feedback_id` 的后续事件应更新已有记录。
|
||||||
|
- `feedback_type == 3` 表示用户取消/移除反馈,处理器可删除对应记录或标记为取消。
|
||||||
|
- 如果平台只能给出原始回调 payload,差异字段保留在 `source_platform_object` 或 `PlatformSpecificEvent.data` 中;通用字段仍优先映射到 `FeedbackReceivedEvent`。
|
||||||
|
- 该事件保留向后兼容映射:EBA 事件可转换为旧的 `FeedbackEvent`,字段语义保持一致。
|
||||||
|
|
||||||
|
### 3.4 群组事件
|
||||||
|
|
||||||
|
#### MemberJoinedEvent (`group.member_joined`)
|
||||||
|
|
||||||
|
新成员加入群组。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MemberJoinedEvent(Event):
|
||||||
|
"""新成员加入群组"""
|
||||||
|
|
||||||
|
type: str = "group.member_joined"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
"""群组"""
|
||||||
|
|
||||||
|
member: User
|
||||||
|
"""加入的成员"""
|
||||||
|
|
||||||
|
inviter: typing.Optional[User] = None
|
||||||
|
"""邀请者(如有)"""
|
||||||
|
|
||||||
|
join_type: typing.Optional[str] = None
|
||||||
|
"""加入方式:'invite' / 'request' / 'direct' / None"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MemberLeftEvent (`group.member_left`)
|
||||||
|
|
||||||
|
成员离开群组。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MemberLeftEvent(Event):
|
||||||
|
"""成员离开群组"""
|
||||||
|
|
||||||
|
type: str = "group.member_left"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
member: User
|
||||||
|
|
||||||
|
is_kicked: bool = False
|
||||||
|
"""是否被踢出"""
|
||||||
|
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
"""操作者(踢出时为管理员)"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MemberBannedEvent (`group.member_banned`)
|
||||||
|
|
||||||
|
成员被禁言。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MemberBannedEvent(Event):
|
||||||
|
"""成员被禁言"""
|
||||||
|
|
||||||
|
type: str = "group.member_banned"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
member: User
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
duration: typing.Optional[int] = None
|
||||||
|
"""禁言时长(秒),None 表示永久"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MemberUnbannedEvent (`group.member_unbanned`)
|
||||||
|
|
||||||
|
成员被解除禁言。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MemberUnbannedEvent(Event):
|
||||||
|
"""成员被解除禁言"""
|
||||||
|
|
||||||
|
type: str = "group.member_unbanned"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
member: User
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GroupInfoUpdatedEvent (`group.info_updated`)
|
||||||
|
|
||||||
|
群组信息被修改。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class GroupInfoUpdatedEvent(Event):
|
||||||
|
"""群组信息被修改"""
|
||||||
|
|
||||||
|
type: str = "group.info_updated"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
"""更新后的群组信息"""
|
||||||
|
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
"""操作者"""
|
||||||
|
|
||||||
|
changed_fields: list[str] = []
|
||||||
|
"""发生变更的字段名列表,如 ['name', 'description']"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 好友事件
|
||||||
|
|
||||||
|
#### FriendRequestReceivedEvent (`friend.request_received`)
|
||||||
|
|
||||||
|
收到好友请求。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FriendRequestReceivedEvent(Event):
|
||||||
|
"""收到好友请求"""
|
||||||
|
|
||||||
|
type: str = "friend.request_received"
|
||||||
|
|
||||||
|
request_id: typing.Union[int, str]
|
||||||
|
"""请求 ID,用于后续 approve/reject 操作"""
|
||||||
|
|
||||||
|
user: User
|
||||||
|
"""请求者"""
|
||||||
|
|
||||||
|
message: typing.Optional[str] = None
|
||||||
|
"""验证消息"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### FriendAddedEvent (`friend.added`)
|
||||||
|
|
||||||
|
成功添加好友。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FriendAddedEvent(Event):
|
||||||
|
"""成功添加好友"""
|
||||||
|
|
||||||
|
type: str = "friend.added"
|
||||||
|
|
||||||
|
user: User
|
||||||
|
"""新好友"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### FriendRemovedEvent (`friend.removed`)
|
||||||
|
|
||||||
|
好友被移除。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FriendRemovedEvent(Event):
|
||||||
|
"""好友被移除"""
|
||||||
|
|
||||||
|
type: str = "friend.removed"
|
||||||
|
|
||||||
|
user: User
|
||||||
|
"""被移除的好友"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 Bot 状态事件
|
||||||
|
|
||||||
|
#### BotInvitedToGroupEvent (`bot.invited_to_group`)
|
||||||
|
|
||||||
|
Bot 被邀请加入群组。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BotInvitedToGroupEvent(Event):
|
||||||
|
"""Bot 被邀请加入群组"""
|
||||||
|
|
||||||
|
type: str = "bot.invited_to_group"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
inviter: typing.Optional[User] = None
|
||||||
|
|
||||||
|
request_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
"""邀请请求 ID,某些平台需要 Bot 确认才加入"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### BotRemovedFromGroupEvent (`bot.removed_from_group`)
|
||||||
|
|
||||||
|
Bot 被移出群组。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BotRemovedFromGroupEvent(Event):
|
||||||
|
"""Bot 被移出群组"""
|
||||||
|
|
||||||
|
type: str = "bot.removed_from_group"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
#### BotMutedEvent / BotUnmutedEvent (`bot.muted` / `bot.unmuted`)
|
||||||
|
|
||||||
|
Bot 被禁言/解除禁言。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BotMutedEvent(Event):
|
||||||
|
"""Bot 被禁言"""
|
||||||
|
|
||||||
|
type: str = "bot.muted"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
duration: typing.Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BotUnmutedEvent(Event):
|
||||||
|
"""Bot 被解除禁言"""
|
||||||
|
|
||||||
|
type: str = "bot.unmuted"
|
||||||
|
|
||||||
|
group: Group
|
||||||
|
operator: typing.Optional[User] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 平台特有事件
|
||||||
|
|
||||||
|
对于无法抽象为通用事件的平台特有事件,使用统一的 `PlatformSpecificEvent` 承载:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PlatformSpecificEvent(Event):
|
||||||
|
"""平台特有事件
|
||||||
|
|
||||||
|
适配器无法映射到通用事件类型时,使用此类型承载。
|
||||||
|
插件可以通过 adapter_name + action 来识别和处理。
|
||||||
|
"""
|
||||||
|
|
||||||
|
type: str = "platform.specific"
|
||||||
|
|
||||||
|
action: str
|
||||||
|
"""平台特有的事件动作标识,如 'channel_created', 'pin_message'"""
|
||||||
|
|
||||||
|
data: dict = {}
|
||||||
|
"""事件数据,结构由具体适配器定义"""
|
||||||
|
```
|
||||||
|
|
||||||
|
事件类型字符串格式为 `platform.{adapter_name}.{action}`,例如:
|
||||||
|
- `platform.telegram.chat_member_updated` — Telegram 的群成员信息更新
|
||||||
|
- `platform.discord.channel_created` — Discord 的频道创建
|
||||||
|
- `platform.discord.voice_state_update` — Discord 的语音状态变更
|
||||||
|
- `platform.slack.app_home_opened` — Slack 的 App Home 打开
|
||||||
|
|
||||||
|
## 4. 各平台事件支持矩阵
|
||||||
|
|
||||||
|
下表标注各通用事件在主要平台上的支持情况:
|
||||||
|
|
||||||
|
| 事件 | Telegram | Discord | OneBot(QQ) | 飞书 | 钉钉 | Slack | 微信 | LINE | KOOK |
|
||||||
|
|------|----------|---------|-----------|------|------|-------|------|------|------|
|
||||||
|
| `message.received` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
| `message.edited` | Y | Y | N | Y | N | Y | N | N | Y |
|
||||||
|
| `message.deleted` | Y | Y | Y | Y | N | Y | Y | N | Y |
|
||||||
|
| `message.reaction` | Y | Y | Y | Y | Y | Y | N | N | Y |
|
||||||
|
| `feedback.received` | N | N | N | Y | N | N | Y | N | N |
|
||||||
|
| `group.member_joined` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
| `group.member_left` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
| `group.member_banned` | Y | Y | Y | N | N | N | N | N | N |
|
||||||
|
| `group.info_updated` | Y | Y | Y | Y | Y | Y | N | N | Y |
|
||||||
|
| `friend.request_received` | N | Y | Y | N | N | N | Y | Y | Y |
|
||||||
|
| `friend.added` | N | Y | Y | N | N | N | Y | Y | N |
|
||||||
|
| `bot.invited_to_group` | Y | Y | Y | Y | Y | Y | Y | N | Y |
|
||||||
|
| `bot.removed_from_group` | Y | Y | Y | Y | N | N | Y | N | Y |
|
||||||
|
| `bot.muted` | Y | N | Y | N | N | N | N | N | N |
|
||||||
|
| `bot.unmuted` | Y | N | Y | N | N | N | N | N | N |
|
||||||
|
| `platform.specific` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
|
||||||
|
> 注:此表为初步评估,具体以各平台 SDK/API 文档为准,实施时逐个确认。
|
||||||
|
|
||||||
|
## 5. 事件生命周期
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 平台 SDK 回调触发
|
||||||
|
│
|
||||||
|
2. 适配器 EventConverter.target2yiri(raw_event)
|
||||||
|
│ 将平台原生事件转换为统一 Event 对象
|
||||||
|
│ 无法映射的事件 → PlatformSpecificEvent
|
||||||
|
│
|
||||||
|
3. 适配器回调注册的 listener(event, adapter)
|
||||||
|
│
|
||||||
|
4. RuntimeBot 接收事件
|
||||||
|
│
|
||||||
|
5. EventBus 分发
|
||||||
|
│
|
||||||
|
6. EventRouter 查询 Bot 的 event_handlers 配置
|
||||||
|
│ 匹配事件类型 → 找到对应的 Handler
|
||||||
|
│ 支持通配符:'message.*' 匹配所有消息事件
|
||||||
|
│ 未匹配到 → 走默认 Handler(plugin,保持向后兼容)
|
||||||
|
│
|
||||||
|
7. Handler 处理事件
|
||||||
|
│ PipelineHandler → 进入 Pipeline 流水线
|
||||||
|
│ AgentHandler → 调用 RequestRunner
|
||||||
|
│ WebhookHandler → POST 到外部 URL
|
||||||
|
│ PluginHandler → 分发给插件 EventListener
|
||||||
|
│
|
||||||
|
8. Handler 执行完毕,可能通过 API 执行响应动作
|
||||||
|
(发消息、编辑消息、踢人、同意好友请求等)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 与现有事件类型的兼容映射
|
||||||
|
|
||||||
|
为保证现有插件不受影响,建立以下映射关系:
|
||||||
|
|
||||||
|
| 新事件 | 条件 | 旧事件 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| `MessageReceivedEvent` (chat_type=private) | — | `FriendMessage` |
|
||||||
|
| `MessageReceivedEvent` (chat_type=group) | — | `GroupMessage` |
|
||||||
|
|
||||||
|
在插件 SDK 层面:
|
||||||
|
|
||||||
|
| 新事件 | 旧插件事件 |
|
||||||
|
|--------|-----------|
|
||||||
|
| `MessageReceivedEvent` (chat_type=private, 非命令) | `PersonNormalMessageReceived` |
|
||||||
|
| `MessageReceivedEvent` (chat_type=group, 非命令) | `GroupNormalMessageReceived` |
|
||||||
|
| `MessageReceivedEvent` (chat_type=private, 命令) | `PersonCommandSent` |
|
||||||
|
| `MessageReceivedEvent` (chat_type=group, 命令) | `GroupCommandSent` |
|
||||||
|
| `MessageReceivedEvent` (处理完毕后) | `NormalMessageResponded` |
|
||||||
|
|
||||||
|
兼容层在事件分发给插件 EventListener 时自动生成旧格式事件,确保监听旧事件类型的插件仍能正常工作。
|
||||||
|
|
||||||
|
## 7. 事件类型注册表
|
||||||
|
|
||||||
|
适配器在 manifest.yaml 中声明自己支持的事件类型:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: MessagePlatformAdapter
|
||||||
|
metadata:
|
||||||
|
name: telegram
|
||||||
|
spec:
|
||||||
|
supported_events:
|
||||||
|
- message.received
|
||||||
|
- message.edited
|
||||||
|
- message.deleted
|
||||||
|
- message.reaction
|
||||||
|
- feedback.received
|
||||||
|
- group.member_joined
|
||||||
|
- group.member_left
|
||||||
|
- group.member_banned
|
||||||
|
- group.info_updated
|
||||||
|
- bot.invited_to_group
|
||||||
|
- bot.removed_from_group
|
||||||
|
- bot.muted
|
||||||
|
- bot.unmuted
|
||||||
|
- platform.specific
|
||||||
|
platform_specific_events:
|
||||||
|
- chat_member_updated
|
||||||
|
- chat_join_request
|
||||||
|
```
|
||||||
|
|
||||||
|
这份声明用于:
|
||||||
|
1. WebUI 在配置事件处理器时,只显示当前 Bot 的适配器支持的事件类型
|
||||||
|
2. EventRouter 在路由时校验事件类型有效性
|
||||||
|
3. 文档自动生成
|
||||||
546
docs/event-based-agents/02-platform-api.md
Normal file
546
docs/event-based-agents/02-platform-api.md
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
# 统一平台 API 与实体定义
|
||||||
|
|
||||||
|
## 1. 设计原则
|
||||||
|
|
||||||
|
- **通用 API 抽象**:大部分平台都支持的操作(发消息、获取群信息等)定义为通用 API 方法
|
||||||
|
- **required / optional 标记**:每个 API 标记为必需或可选,适配器未实现可选 API 时抛出 `NotSupportedError`
|
||||||
|
- **透传机制**:适配器特有的操作通过 `call_platform_api(action, params)` 统一入口透传调用
|
||||||
|
- **能力声明**:适配器在 manifest 中声明自己支持的 API 列表,供 WebUI 和插件查询
|
||||||
|
- **实体统一**:通用实体(User、Group 等)在 SDK 层面统一定义,适配器负责转换
|
||||||
|
|
||||||
|
## 2. 通用实体定义
|
||||||
|
|
||||||
|
### 2.1 现有实体回顾
|
||||||
|
|
||||||
|
当前 SDK 已有以下实体(`langbot_plugin/api/entities/builtin/platform/entities.py`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
Entity(id)
|
||||||
|
├── Friend(id, nickname, remark)
|
||||||
|
├── Group(id, name, permission)
|
||||||
|
└── GroupMember(id, member_name, permission, group, special_title)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 新实体设计
|
||||||
|
|
||||||
|
扩展实体体系,保持向后兼容:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class User(pydantic.BaseModel):
|
||||||
|
"""用户实体(统一表示)"""
|
||||||
|
|
||||||
|
id: typing.Union[int, str]
|
||||||
|
"""用户 ID"""
|
||||||
|
|
||||||
|
nickname: str = ""
|
||||||
|
"""昵称"""
|
||||||
|
|
||||||
|
avatar_url: typing.Optional[str] = None
|
||||||
|
"""头像 URL"""
|
||||||
|
|
||||||
|
is_bot: bool = False
|
||||||
|
"""是否为 Bot"""
|
||||||
|
|
||||||
|
# 以下为可选的扩展信息,不同平台可能部分为空
|
||||||
|
username: typing.Optional[str] = None
|
||||||
|
"""用户名(如 Telegram 的 @username)"""
|
||||||
|
|
||||||
|
remark: typing.Optional[str] = None
|
||||||
|
"""备注名"""
|
||||||
|
|
||||||
|
|
||||||
|
class Group(pydantic.BaseModel):
|
||||||
|
"""群组实体"""
|
||||||
|
|
||||||
|
id: typing.Union[int, str]
|
||||||
|
"""群组 ID"""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
"""群组名称"""
|
||||||
|
|
||||||
|
description: typing.Optional[str] = None
|
||||||
|
"""群组描述"""
|
||||||
|
|
||||||
|
member_count: typing.Optional[int] = None
|
||||||
|
"""成员数量"""
|
||||||
|
|
||||||
|
avatar_url: typing.Optional[str] = None
|
||||||
|
"""群组头像 URL"""
|
||||||
|
|
||||||
|
owner_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
"""群主 ID"""
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMember(pydantic.BaseModel):
|
||||||
|
"""群成员实体"""
|
||||||
|
|
||||||
|
user: User
|
||||||
|
"""用户信息"""
|
||||||
|
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
"""所属群组 ID"""
|
||||||
|
|
||||||
|
role: MemberRole
|
||||||
|
"""群内角色"""
|
||||||
|
|
||||||
|
display_name: typing.Optional[str] = None
|
||||||
|
"""群内显示名"""
|
||||||
|
|
||||||
|
joined_at: typing.Optional[float] = None
|
||||||
|
"""加入群组的时间戳"""
|
||||||
|
|
||||||
|
title: typing.Optional[str] = None
|
||||||
|
"""群头衔/特殊称号"""
|
||||||
|
|
||||||
|
|
||||||
|
class MemberRole(str, Enum):
|
||||||
|
"""群成员角色"""
|
||||||
|
OWNER = "owner"
|
||||||
|
ADMIN = "admin"
|
||||||
|
MEMBER = "member"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 与现有实体的兼容映射
|
||||||
|
|
||||||
|
| 新实体 | 旧实体 | 映射方式 |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| `User` | `Friend` | `User(id=friend.id, nickname=friend.nickname, remark=friend.remark)` |
|
||||||
|
| `Group` | `Group`(旧) | `Group(id=old.id, name=old.name)` + `permission` 字段弃用 |
|
||||||
|
| `GroupMember` | `GroupMember`(旧) | `GroupMember(user=User(...), role=..., display_name=old.member_name)` |
|
||||||
|
| `MemberRole` | `Permission` | `OWNER↔Owner`, `ADMIN↔Administrator`, `MEMBER↔Member` |
|
||||||
|
|
||||||
|
旧实体类保留,标记为 `@deprecated`,内部通过转换方法桥接到新实体。
|
||||||
|
|
||||||
|
## 3. 通用 API 定义
|
||||||
|
|
||||||
|
### 3.1 API 方法一览
|
||||||
|
|
||||||
|
#### 消息 API
|
||||||
|
|
||||||
|
| 方法 | 必需/可选 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `send_message(target_type, target_id, message)` | **必需** | 主动发送消息 |
|
||||||
|
| `reply_message(event, message, quote_origin)` | **必需** | 回复一个消息事件 |
|
||||||
|
| `edit_message(chat_type, chat_id, message_id, new_content)` | 可选 | 编辑已发送的消息 |
|
||||||
|
| `delete_message(chat_type, chat_id, message_id)` | 可选 | 删除/撤回消息 |
|
||||||
|
| `forward_message(from_chat, message_id, to_chat_type, to_chat_id)` | 可选 | 转发消息到另一个会话 |
|
||||||
|
| `get_message(chat_type, chat_id, message_id)` | 可选 | 获取指定消息的内容 |
|
||||||
|
|
||||||
|
#### 群组 API
|
||||||
|
|
||||||
|
| 方法 | 必需/可选 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `get_group_info(group_id)` | 可选 | 获取群组信息 |
|
||||||
|
| `get_group_list()` | 可选 | 获取 Bot 加入的群组列表 |
|
||||||
|
| `get_group_member_list(group_id)` | 可选 | 获取群成员列表 |
|
||||||
|
| `get_group_member_info(group_id, user_id)` | 可选 | 获取指定群成员信息 |
|
||||||
|
| `set_group_name(group_id, name)` | 可选 | 修改群名称 |
|
||||||
|
| `mute_member(group_id, user_id, duration)` | 可选 | 禁言群成员 |
|
||||||
|
| `unmute_member(group_id, user_id)` | 可选 | 解除禁言 |
|
||||||
|
| `kick_member(group_id, user_id)` | 可选 | 踢出群成员 |
|
||||||
|
| `leave_group(group_id)` | 可选 | Bot 退出群组 |
|
||||||
|
|
||||||
|
#### 用户 API
|
||||||
|
|
||||||
|
| 方法 | 必需/可选 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `get_user_info(user_id)` | 可选 | 获取用户信息 |
|
||||||
|
| `get_friend_list()` | 可选 | 获取好友列表 |
|
||||||
|
| `approve_friend_request(request_id, approve, remark)` | 可选 | 处理好友请求 |
|
||||||
|
| `approve_group_invite(request_id, approve)` | 可选 | 处理入群邀请 |
|
||||||
|
|
||||||
|
#### 媒体 API
|
||||||
|
|
||||||
|
| 方法 | 必需/可选 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `upload_file(file_data, filename)` | 可选 | 上传文件,返回可引用的文件 ID 或 URL |
|
||||||
|
| `get_file_url(file_id)` | 可选 | 获取文件下载 URL |
|
||||||
|
|
||||||
|
#### 透传 API
|
||||||
|
|
||||||
|
| 方法 | 必需/可选 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `call_platform_api(action, params)` | 可选 | 调用适配器特有 API |
|
||||||
|
|
||||||
|
### 3.2 API 方法签名详解
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AbstractPlatformAdapter(pydantic.BaseModel, metaclass=abc.ABCMeta):
|
||||||
|
"""平台适配器基类(新版)"""
|
||||||
|
|
||||||
|
# ======== 必需方法 ========
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def send_message(
|
||||||
|
self,
|
||||||
|
target_type: str, # "private" | "group"
|
||||||
|
target_id: typing.Union[int, str],
|
||||||
|
message: MessageChain,
|
||||||
|
) -> MessageResult:
|
||||||
|
"""主动发送消息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MessageResult: 包含 message_id 等发送结果
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def reply_message(
|
||||||
|
self,
|
||||||
|
event: MessageReceivedEvent,
|
||||||
|
message: MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
) -> MessageResult:
|
||||||
|
"""回复一个消息事件"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# ======== 可选消息方法 ========
|
||||||
|
|
||||||
|
async def edit_message(
|
||||||
|
self,
|
||||||
|
chat_type: str,
|
||||||
|
chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
new_content: MessageChain,
|
||||||
|
) -> None:
|
||||||
|
"""编辑已发送的消息"""
|
||||||
|
raise NotSupportedError("edit_message")
|
||||||
|
|
||||||
|
async def delete_message(
|
||||||
|
self,
|
||||||
|
chat_type: str,
|
||||||
|
chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""删除/撤回消息"""
|
||||||
|
raise NotSupportedError("delete_message")
|
||||||
|
|
||||||
|
async def forward_message(
|
||||||
|
self,
|
||||||
|
from_chat_type: str,
|
||||||
|
from_chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
to_chat_type: str,
|
||||||
|
to_chat_id: typing.Union[int, str],
|
||||||
|
) -> MessageResult:
|
||||||
|
"""转发消息"""
|
||||||
|
raise NotSupportedError("forward_message")
|
||||||
|
|
||||||
|
async def get_message(
|
||||||
|
self,
|
||||||
|
chat_type: str,
|
||||||
|
chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
) -> MessageReceivedEvent:
|
||||||
|
"""获取指定消息"""
|
||||||
|
raise NotSupportedError("get_message")
|
||||||
|
|
||||||
|
# ======== 可选群组方法 ========
|
||||||
|
|
||||||
|
async def get_group_info(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
) -> Group:
|
||||||
|
"""获取群组信息"""
|
||||||
|
raise NotSupportedError("get_group_info")
|
||||||
|
|
||||||
|
async def get_group_list(self) -> list[Group]:
|
||||||
|
"""获取 Bot 加入的群组列表"""
|
||||||
|
raise NotSupportedError("get_group_list")
|
||||||
|
|
||||||
|
async def get_group_member_list(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
) -> list[GroupMember]:
|
||||||
|
"""获取群成员列表"""
|
||||||
|
raise NotSupportedError("get_group_member_list")
|
||||||
|
|
||||||
|
async def get_group_member_info(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> GroupMember:
|
||||||
|
"""获取指定群成员信息"""
|
||||||
|
raise NotSupportedError("get_group_member_info")
|
||||||
|
|
||||||
|
async def set_group_name(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
name: str,
|
||||||
|
) -> None:
|
||||||
|
"""修改群名称"""
|
||||||
|
raise NotSupportedError("set_group_name")
|
||||||
|
|
||||||
|
async def mute_member(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
duration: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""禁言群成员,duration 为秒数,0 表示永久"""
|
||||||
|
raise NotSupportedError("mute_member")
|
||||||
|
|
||||||
|
async def unmute_member(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""解除禁言"""
|
||||||
|
raise NotSupportedError("unmute_member")
|
||||||
|
|
||||||
|
async def kick_member(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""踢出群成员"""
|
||||||
|
raise NotSupportedError("kick_member")
|
||||||
|
|
||||||
|
async def leave_group(
|
||||||
|
self,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""Bot 退出群组"""
|
||||||
|
raise NotSupportedError("leave_group")
|
||||||
|
|
||||||
|
# ======== 可选用户方法 ========
|
||||||
|
|
||||||
|
async def get_user_info(
|
||||||
|
self,
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> User:
|
||||||
|
"""获取用户信息"""
|
||||||
|
raise NotSupportedError("get_user_info")
|
||||||
|
|
||||||
|
async def get_friend_list(self) -> list[User]:
|
||||||
|
"""获取好友列表"""
|
||||||
|
raise NotSupportedError("get_friend_list")
|
||||||
|
|
||||||
|
async def approve_friend_request(
|
||||||
|
self,
|
||||||
|
request_id: typing.Union[int, str],
|
||||||
|
approve: bool = True,
|
||||||
|
remark: typing.Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""处理好友请求"""
|
||||||
|
raise NotSupportedError("approve_friend_request")
|
||||||
|
|
||||||
|
async def approve_group_invite(
|
||||||
|
self,
|
||||||
|
request_id: typing.Union[int, str],
|
||||||
|
approve: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""处理入群邀请"""
|
||||||
|
raise NotSupportedError("approve_group_invite")
|
||||||
|
|
||||||
|
# ======== 可选媒体方法 ========
|
||||||
|
|
||||||
|
async def upload_file(
|
||||||
|
self,
|
||||||
|
file_data: bytes,
|
||||||
|
filename: str,
|
||||||
|
) -> str:
|
||||||
|
"""上传文件,返回文件 ID 或 URL"""
|
||||||
|
raise NotSupportedError("upload_file")
|
||||||
|
|
||||||
|
async def get_file_url(
|
||||||
|
self,
|
||||||
|
file_id: str,
|
||||||
|
) -> str:
|
||||||
|
"""获取文件下载 URL"""
|
||||||
|
raise NotSupportedError("get_file_url")
|
||||||
|
|
||||||
|
# ======== 透传 API ========
|
||||||
|
|
||||||
|
async def call_platform_api(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
params: dict = {},
|
||||||
|
) -> dict:
|
||||||
|
"""调用适配器特有 API
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action: 平台特有的 API 动作标识
|
||||||
|
params: 参数字典
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 返回结果
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Telegram: pin 消息
|
||||||
|
await adapter.call_platform_api("pin_message", {
|
||||||
|
"chat_id": 123456,
|
||||||
|
"message_id": 789
|
||||||
|
})
|
||||||
|
|
||||||
|
# Discord: 创建频道
|
||||||
|
await adapter.call_platform_api("create_channel", {
|
||||||
|
"guild_id": "...",
|
||||||
|
"name": "new-channel",
|
||||||
|
"type": "text"
|
||||||
|
})
|
||||||
|
"""
|
||||||
|
raise NotSupportedError("call_platform_api")
|
||||||
|
|
||||||
|
# ======== 流式输出(保留现有机制) ========
|
||||||
|
|
||||||
|
async def reply_message_chunk(
|
||||||
|
self,
|
||||||
|
event: MessageReceivedEvent,
|
||||||
|
bot_message: dict,
|
||||||
|
message: MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
is_final: bool = False,
|
||||||
|
):
|
||||||
|
"""流式回复消息"""
|
||||||
|
raise NotSupportedError("reply_message_chunk")
|
||||||
|
|
||||||
|
async def is_stream_output_supported(self) -> bool:
|
||||||
|
"""是否支持流式输出"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ======== 生命周期方法(保留现有) ========
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def run_async(self):
|
||||||
|
"""启动适配器"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def kill(self) -> bool:
|
||||||
|
"""停止适配器"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def register_listener(self, event_type, callback):
|
||||||
|
"""注册事件监听器"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def unregister_listener(self, event_type, callback):
|
||||||
|
"""注销事件监听器"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 返回值类型
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MessageResult(pydantic.BaseModel):
|
||||||
|
"""消息发送结果"""
|
||||||
|
|
||||||
|
message_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
"""发送成功后的消息 ID"""
|
||||||
|
|
||||||
|
raw: typing.Optional[dict] = None
|
||||||
|
"""平台原始返回数据"""
|
||||||
|
|
||||||
|
|
||||||
|
class NotSupportedError(Exception):
|
||||||
|
"""适配器未实现此 API"""
|
||||||
|
|
||||||
|
def __init__(self, api_name: str):
|
||||||
|
self.api_name = api_name
|
||||||
|
super().__init__(f"API not supported by this adapter: {api_name}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. API 能力声明
|
||||||
|
|
||||||
|
适配器在 manifest.yaml 中声明支持的 API:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: MessagePlatformAdapter
|
||||||
|
metadata:
|
||||||
|
name: telegram
|
||||||
|
spec:
|
||||||
|
supported_apis:
|
||||||
|
required:
|
||||||
|
- send_message
|
||||||
|
- reply_message
|
||||||
|
optional:
|
||||||
|
- edit_message
|
||||||
|
- delete_message
|
||||||
|
- get_group_info
|
||||||
|
- get_group_member_list
|
||||||
|
- get_user_info
|
||||||
|
- upload_file
|
||||||
|
- get_file_url
|
||||||
|
- call_platform_api
|
||||||
|
platform_specific_apis:
|
||||||
|
- action: pin_message
|
||||||
|
description: "Pin a message in a chat"
|
||||||
|
params_schema:
|
||||||
|
chat_id: { type: "string", required: true }
|
||||||
|
message_id: { type: "string", required: true }
|
||||||
|
- action: unpin_message
|
||||||
|
description: "Unpin a message"
|
||||||
|
params_schema:
|
||||||
|
chat_id: { type: "string", required: true }
|
||||||
|
message_id: { type: "string", required: true }
|
||||||
|
```
|
||||||
|
|
||||||
|
用途:
|
||||||
|
1. **WebUI**:在配置界面展示当前 Bot 可用的 API 能力
|
||||||
|
2. **插件**:插件可查询某个 Bot 是否支持特定 API,据此决定行为
|
||||||
|
3. **文档**:自动生成各适配器的 API 支持矩阵
|
||||||
|
|
||||||
|
## 5. 各平台 API 支持矩阵
|
||||||
|
|
||||||
|
| API | Telegram | Discord | OneBot(QQ) | 飞书 | 钉钉 | Slack | 微信 | LINE | KOOK |
|
||||||
|
|-----|----------|---------|-----------|------|------|-------|------|------|------|
|
||||||
|
| `send_message` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
| `reply_message` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
| `edit_message` | Y | Y | N | Y | N | Y | N | N | Y |
|
||||||
|
| `delete_message` | Y | Y | Y | Y | N | Y | Y | N | Y |
|
||||||
|
| `forward_message` | Y | N | Y | Y | N | N | Y | N | N |
|
||||||
|
| `get_group_info` | Y | Y | Y | Y | Y | Y | N | Y | Y |
|
||||||
|
| `get_group_member_list` | Y | Y | Y | Y | Y | Y | N | Y | Y |
|
||||||
|
| `get_user_info` | Y | Y | Y | Y | Y | Y | N | Y | Y |
|
||||||
|
| `get_friend_list` | N | Y | Y | N | N | N | Y | N | N |
|
||||||
|
| `mute_member` | Y | Y | Y | N | N | N | N | N | N |
|
||||||
|
| `kick_member` | Y | Y | Y | N | N | N | N | N | Y |
|
||||||
|
| `upload_file` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
| `call_platform_api` | Y | Y | Y | Y | Y | Y | Y | Y | Y |
|
||||||
|
|
||||||
|
> 注:此表为初步评估,具体以各平台 SDK/API 文档为准。
|
||||||
|
|
||||||
|
## 6. MessageChain 扩展
|
||||||
|
|
||||||
|
### 6.1 保留的通用组件
|
||||||
|
|
||||||
|
以下 MessageComponent 类型保持不变,继续作为通用消息元素:
|
||||||
|
|
||||||
|
- `Source` — 消息元信息
|
||||||
|
- `Plain` — 纯文本
|
||||||
|
- `Quote` — 引用回复
|
||||||
|
- `At` / `AtAll` — @提及
|
||||||
|
- `Image` — 图片
|
||||||
|
- `Voice` — 语音
|
||||||
|
- `File` — 文件
|
||||||
|
- `Forward` — 合并转发
|
||||||
|
- `Face` — 表情
|
||||||
|
- `Unknown` — 未知类型
|
||||||
|
|
||||||
|
### 6.2 平台特有组件处理
|
||||||
|
|
||||||
|
当前 MessageChain 中存在大量微信特有的组件类型(`WeChatMiniPrograms`, `WeChatEmoji`, `WeChatLink` 等)。在新架构下:
|
||||||
|
|
||||||
|
- 这些类型**继续保留**在 SDK 中以保持兼容
|
||||||
|
- 新增的平台特有消息组件统一使用 `PlatformComponent` 基类:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PlatformComponent(MessageComponent):
|
||||||
|
"""平台特有的消息组件"""
|
||||||
|
|
||||||
|
type: str = "Platform"
|
||||||
|
|
||||||
|
platform: str
|
||||||
|
"""平台标识"""
|
||||||
|
|
||||||
|
component_type: str
|
||||||
|
"""组件类型"""
|
||||||
|
|
||||||
|
data: dict = {}
|
||||||
|
"""组件数据"""
|
||||||
|
```
|
||||||
|
|
||||||
|
适配器在转换消息链时,对于无法映射到通用组件的平台特有内容,使用 `PlatformComponent` 承载。
|
||||||
483
docs/event-based-agents/03-adapter-structure.md
Normal file
483
docs/event-based-agents/03-adapter-structure.md
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
# 适配器新目录结构
|
||||||
|
|
||||||
|
## 1. 设计目标
|
||||||
|
|
||||||
|
- **模块化**:每个适配器从单文件拆分到独立目录,各模块职责清晰
|
||||||
|
- **可维护**:随着事件和 API 的增加,代码量会显著增长,目录结构有助于管理复杂度
|
||||||
|
- **一致性**:所有适配器遵循相同的目录布局和文件命名约定
|
||||||
|
- **兼容现有发现机制**:保持 YAML manifest + ComponentDiscoveryEngine 的注册体系
|
||||||
|
|
||||||
|
## 2. 新目录布局
|
||||||
|
|
||||||
|
### 2.1 整体结构
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/platform/
|
||||||
|
├── __init__.py
|
||||||
|
├── botmgr.py # PlatformManager + RuntimeBot(重构)
|
||||||
|
├── event_bus.py # EventBus(新增)
|
||||||
|
├── event_router.py # EventRouter(新增)
|
||||||
|
├── logger.py # EventLogger(保留)
|
||||||
|
├── webhook_pusher.py # WebhookPusher(重构为 WebhookHandler)
|
||||||
|
│
|
||||||
|
├── adapters/ # 适配器(新目录)
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ │
|
||||||
|
│ ├── telegram/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── adapter.py # TelegramAdapter 主类
|
||||||
|
│ │ ├── event_converter.py # 平台事件 → 统一事件
|
||||||
|
│ │ ├── message_converter.py # MessageChain 互转
|
||||||
|
│ │ ├── api_impl.py # 通用 API 实现
|
||||||
|
│ │ ├── platform_api.py # call_platform_api 的动作映射
|
||||||
|
│ │ ├── types.py # 平台特有类型定义
|
||||||
|
│ │ └── manifest.yaml # 适配器清单
|
||||||
|
│ │
|
||||||
|
│ ├── discord/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── adapter.py
|
||||||
|
│ │ ├── event_converter.py
|
||||||
|
│ │ ├── message_converter.py
|
||||||
|
│ │ ├── api_impl.py
|
||||||
|
│ │ ├── platform_api.py
|
||||||
|
│ │ ├── types.py
|
||||||
|
│ │ ├── voice.py # Discord 语音连接管理(特有)
|
||||||
|
│ │ └── manifest.yaml
|
||||||
|
│ │
|
||||||
|
│ ├── aiocqhttp/ # OneBot v11 (QQ)
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── qqofficial/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── lark/ # 飞书
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── dingtalk/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── slack/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── wechatpad/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── officialaccount/ # 微信公众号
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── wecom/ # 企业微信
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── wecombot/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── wecomcs/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── kook/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── line/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── satori/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── websocket/ # 内置 WebSocket 适配器
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── adapter.py
|
||||||
|
│ │ ├── manager.py # WebSocket 连接管理
|
||||||
|
│ │ └── manifest.yaml
|
||||||
|
│ │
|
||||||
|
│ └── legacy/ # 旧版适配器(保留一段时间后移除)
|
||||||
|
│ ├── gewechat/
|
||||||
|
│ ├── nakuru/
|
||||||
|
│ └── qqbotpy/
|
||||||
|
│
|
||||||
|
└── handlers/ # 事件处理器实现(新增)
|
||||||
|
├── __init__.py
|
||||||
|
├── base.py # AbstractEventHandler 基类
|
||||||
|
├── pipeline_handler.py # PipelineHandler
|
||||||
|
├── agent_handler.py # AgentHandler
|
||||||
|
├── webhook_handler.py # WebhookHandler
|
||||||
|
└── plugin_handler.py # PluginHandler
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 适配器目录内各文件职责
|
||||||
|
|
||||||
|
以 Telegram 为例:
|
||||||
|
|
||||||
|
| 文件 | 职责 | 关键类/函数 |
|
||||||
|
|------|------|------------|
|
||||||
|
| `adapter.py` | 主入口,继承 `AbstractPlatformAdapter`,组装其他模块 | `TelegramAdapter` |
|
||||||
|
| `event_converter.py` | 将 Telegram 原生事件转换为统一事件类型 | `TelegramEventConverter` — 支持 Message/Edit/Delete/Reaction/MemberJoin 等所有事件 |
|
||||||
|
| `message_converter.py` | `MessageChain` 与 Telegram 消息格式互转 | `TelegramMessageConverter.yiri2target()` / `target2yiri()` |
|
||||||
|
| `api_impl.py` | 实现通用 API 方法(edit_message, delete_message, get_group_info 等) | 各 API 方法的 Telegram 实现 |
|
||||||
|
| `platform_api.py` | 实现 `call_platform_api` 的动作分发表 | `PLATFORM_API_MAP = {"pin_message": ..., "unpin_message": ...}` |
|
||||||
|
| `types.py` | 平台特有的类型定义 | Telegram 特有的枚举、配置结构等 |
|
||||||
|
| `manifest.yaml` | 适配器清单:名称、配置 schema、支持的事件和 API 列表 | — |
|
||||||
|
|
||||||
|
## 3. 新基类设计
|
||||||
|
|
||||||
|
### 3.1 AbstractPlatformAdapter
|
||||||
|
|
||||||
|
新基类继承自现有 `AbstractMessagePlatformAdapter` 并扩展,位于 `langbot-plugin-sdk` 中:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# langbot_plugin/api/definition/abstract/platform/adapter.py
|
||||||
|
|
||||||
|
class AbstractPlatformAdapter(pydantic.BaseModel, metaclass=abc.ABCMeta):
|
||||||
|
"""平台适配器基类(EBA 版本)
|
||||||
|
|
||||||
|
相比旧版 AbstractMessagePlatformAdapter:
|
||||||
|
- 新增通用 API 方法(edit_message, delete_message, get_group_info 等)
|
||||||
|
- 新增透传 API(call_platform_api)
|
||||||
|
- 新增能力声明(get_supported_events, get_supported_apis)
|
||||||
|
- 事件监听器支持所有事件类型,不仅限于消息事件
|
||||||
|
"""
|
||||||
|
|
||||||
|
bot_account_id: str = ""
|
||||||
|
config: dict
|
||||||
|
logger: AbstractEventLogger = pydantic.Field(exclude=True)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
arbitrary_types_allowed = True
|
||||||
|
|
||||||
|
# ---- 能力声明 ----
|
||||||
|
|
||||||
|
def get_supported_events(self) -> list[str]:
|
||||||
|
"""返回此适配器支持的事件类型列表
|
||||||
|
|
||||||
|
默认实现从 manifest.yaml 读取。
|
||||||
|
适配器也可以 override 此方法动态声明。
|
||||||
|
"""
|
||||||
|
return ["message.received"]
|
||||||
|
|
||||||
|
def get_supported_apis(self) -> list[str]:
|
||||||
|
"""返回此适配器支持的 API 列表
|
||||||
|
|
||||||
|
默认实现从 manifest.yaml 读取。
|
||||||
|
"""
|
||||||
|
return ["send_message", "reply_message"]
|
||||||
|
|
||||||
|
# ---- 必需方法(抽象) ----
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def send_message(self, target_type, target_id, message) -> MessageResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def reply_message(self, event, message, quote_origin=False) -> MessageResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def run_async(self):
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def kill(self) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def register_listener(self, event_type, callback):
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def unregister_listener(self, event_type, callback):
|
||||||
|
...
|
||||||
|
|
||||||
|
# ---- 可选方法(默认抛 NotSupportedError) ----
|
||||||
|
# edit_message, delete_message, forward_message,
|
||||||
|
# get_group_info, get_group_member_list, ...
|
||||||
|
# call_platform_api, ...
|
||||||
|
# (完整签名见 02-platform-api.md)
|
||||||
|
|
||||||
|
# ---- 流式输出(保留) ----
|
||||||
|
|
||||||
|
async def reply_message_chunk(self, event, bot_message, message,
|
||||||
|
quote_origin=False, is_final=False):
|
||||||
|
raise NotSupportedError("reply_message_chunk")
|
||||||
|
|
||||||
|
async def is_stream_output_supported(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---- 消息卡片(保留) ----
|
||||||
|
|
||||||
|
async def create_message_card(self, message_id, event) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def is_muted(self, group_id) -> bool:
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 AbstractMessagePlatformAdapter 兼容
|
||||||
|
|
||||||
|
旧的 `AbstractMessagePlatformAdapter` 保留为 `AbstractPlatformAdapter` 的类型别名:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 向后兼容
|
||||||
|
AbstractMessagePlatformAdapter = AbstractPlatformAdapter
|
||||||
|
```
|
||||||
|
|
||||||
|
现有适配器代码中的 `AbstractMessagePlatformAdapter` 引用不需要立即修改。
|
||||||
|
|
||||||
|
### 3.3 EventConverter 新设计
|
||||||
|
|
||||||
|
现有 `AbstractEventConverter` 只有 `target2yiri` 和 `yiri2target` 两个静态方法,且只处理消息事件。
|
||||||
|
|
||||||
|
新设计支持多种事件类型:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AbstractEventConverter:
|
||||||
|
"""事件转换器基类(EBA 版本)
|
||||||
|
|
||||||
|
适配器需要实现此转换器,将平台原生事件转换为统一事件。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def target2yiri(raw_event: typing.Any) -> typing.Optional[Event]:
|
||||||
|
"""将平台原生事件转换为统一事件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_event: 平台 SDK 回调传入的原始事件对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
统一 Event 对象,如果无法转换或不需要处理则返回 None
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def yiri2target(event: Event) -> typing.Any:
|
||||||
|
"""将统一事件转换为平台原生事件(一般不需要)"""
|
||||||
|
raise NotImplementedError
|
||||||
|
```
|
||||||
|
|
||||||
|
具体适配器的 EventConverter 实现会是一个分发式的结构:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TelegramEventConverter(AbstractEventConverter):
|
||||||
|
"""Telegram 事件转换器"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def target2yiri(update: telegram.Update) -> typing.Optional[Event]:
|
||||||
|
# 消息事件
|
||||||
|
if update.message:
|
||||||
|
return TelegramEventConverter._convert_message(update)
|
||||||
|
# 消息编辑
|
||||||
|
if update.edited_message:
|
||||||
|
return TelegramEventConverter._convert_edited_message(update)
|
||||||
|
# 成员变动
|
||||||
|
if update.chat_member:
|
||||||
|
return TelegramEventConverter._convert_chat_member(update)
|
||||||
|
# 回调查询(按钮点击等)
|
||||||
|
if update.callback_query:
|
||||||
|
return TelegramEventConverter._convert_callback_query(update)
|
||||||
|
# 其他 → PlatformSpecificEvent
|
||||||
|
return TelegramEventConverter._convert_platform_specific(update)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_message(update) -> MessageReceivedEvent:
|
||||||
|
...
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_edited_message(update) -> MessageEditedEvent:
|
||||||
|
...
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_chat_member(update) -> typing.Union[
|
||||||
|
MemberJoinedEvent, MemberLeftEvent, ...
|
||||||
|
]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_platform_specific(update) -> PlatformSpecificEvent:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Manifest 文件格式扩展
|
||||||
|
|
||||||
|
现有 manifest.yaml 只声明 `kind`, `metadata`, `spec.config`, `execution`。
|
||||||
|
|
||||||
|
新增 `spec.supported_events` 和 `spec.supported_apis`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: MessagePlatformAdapter
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
name: telegram
|
||||||
|
label:
|
||||||
|
en_US: Telegram
|
||||||
|
zh_Hans: Telegram
|
||||||
|
icon: telegram.svg
|
||||||
|
description:
|
||||||
|
en_US: Telegram Bot adapter
|
||||||
|
zh_Hans: Telegram Bot 适配器
|
||||||
|
|
||||||
|
spec:
|
||||||
|
config:
|
||||||
|
# 现有配置 schema(保持不变)
|
||||||
|
- key: token
|
||||||
|
label: { en_US: "Bot Token", zh_Hans: "Bot Token" }
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
sensitive: true
|
||||||
|
# ...
|
||||||
|
|
||||||
|
supported_events:
|
||||||
|
- message.received
|
||||||
|
- message.edited
|
||||||
|
- message.deleted
|
||||||
|
- message.reaction
|
||||||
|
- feedback.received
|
||||||
|
- group.member_joined
|
||||||
|
- group.member_left
|
||||||
|
- group.member_banned
|
||||||
|
- group.info_updated
|
||||||
|
- bot.invited_to_group
|
||||||
|
- bot.removed_from_group
|
||||||
|
- bot.muted
|
||||||
|
- bot.unmuted
|
||||||
|
- platform.specific
|
||||||
|
|
||||||
|
supported_apis:
|
||||||
|
required:
|
||||||
|
- send_message
|
||||||
|
- reply_message
|
||||||
|
optional:
|
||||||
|
- edit_message
|
||||||
|
- delete_message
|
||||||
|
- get_group_info
|
||||||
|
- get_group_member_list
|
||||||
|
- get_group_member_info
|
||||||
|
- get_user_info
|
||||||
|
- upload_file
|
||||||
|
- get_file_url
|
||||||
|
- call_platform_api
|
||||||
|
|
||||||
|
platform_specific_apis:
|
||||||
|
- action: pin_message
|
||||||
|
description: { en_US: "Pin a message", zh_Hans: "置顶消息" }
|
||||||
|
- action: unpin_message
|
||||||
|
description: { en_US: "Unpin a message", zh_Hans: "取消置顶" }
|
||||||
|
- action: get_chat_administrators
|
||||||
|
description: { en_US: "Get chat admins", zh_Hans: "获取群管理员列表" }
|
||||||
|
|
||||||
|
execution:
|
||||||
|
python:
|
||||||
|
path: pkg/platform/adapters/telegram/adapter.py
|
||||||
|
attr: TelegramAdapter
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 适配器注册与发现
|
||||||
|
|
||||||
|
### 5.1 Blueprint 更新
|
||||||
|
|
||||||
|
`templates/components.yaml` 中更新扫描路径:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: Blueprint
|
||||||
|
spec:
|
||||||
|
components:
|
||||||
|
MessagePlatformAdapter:
|
||||||
|
fromDirs:
|
||||||
|
- path: pkg/platform/adapters/ # 新路径
|
||||||
|
```
|
||||||
|
|
||||||
|
`ComponentDiscoveryEngine` 的递归扫描逻辑不变——它会扫描所有子目录中的 `.yaml` 文件。因此每个适配器目录下的 `manifest.yaml` 会被自动发现。
|
||||||
|
|
||||||
|
### 5.2 PlatformManager 适配
|
||||||
|
|
||||||
|
`PlatformManager.initialize()` 的核心逻辑基本不变:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def initialize(self):
|
||||||
|
# 1. 发现适配器组件(自动扫描新目录结构)
|
||||||
|
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
||||||
|
|
||||||
|
# 2. 动态导入适配器类
|
||||||
|
for component in self.adapter_components:
|
||||||
|
self.adapter_dict[component.metadata.name] = component.get_python_component_class()
|
||||||
|
|
||||||
|
# 3. 从数据库加载 Bot 并实例化适配器(不变)
|
||||||
|
await self.load_bots_from_db()
|
||||||
|
```
|
||||||
|
|
||||||
|
变更点:
|
||||||
|
- `execution.python.path` 从 `pkg/platform/sources/telegram.py` 变为 `pkg/platform/adapters/telegram/adapter.py`
|
||||||
|
- `get_python_component_class()` 正常工作,因为它按路径动态导入
|
||||||
|
|
||||||
|
## 6. RuntimeBot 重构
|
||||||
|
|
||||||
|
### 6.1 现有问题
|
||||||
|
|
||||||
|
当前 `RuntimeBot.initialize()` 硬编码注册了两个回调:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 现有代码
|
||||||
|
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
||||||
|
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 新设计
|
||||||
|
|
||||||
|
`RuntimeBot` 改为注册一个通用的事件回调:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class RuntimeBot:
|
||||||
|
async def initialize(self):
|
||||||
|
# 注册通用事件回调,接收所有事件类型
|
||||||
|
self.adapter.register_listener(Event, self._on_event)
|
||||||
|
|
||||||
|
async def _on_event(
|
||||||
|
self,
|
||||||
|
event: Event,
|
||||||
|
adapter: AbstractPlatformAdapter,
|
||||||
|
):
|
||||||
|
"""统一事件入口"""
|
||||||
|
|
||||||
|
# 1. 设置事件的 bot_uuid 和 adapter_name
|
||||||
|
event.bot_uuid = self.bot_entity.uuid
|
||||||
|
event.adapter_name = self.bot_entity.adapter
|
||||||
|
|
||||||
|
# 2. 日志记录
|
||||||
|
await self._log_event(event)
|
||||||
|
|
||||||
|
# 3. 提交给 EventBus
|
||||||
|
await self.ap.event_bus.emit(event, adapter)
|
||||||
|
```
|
||||||
|
|
||||||
|
适配器侧的 `register_listener` 实现也需调整:
|
||||||
|
- 当 `event_type` 为 `Event`(基类)时,注册为"接收所有事件"的通配回调
|
||||||
|
- 适配器在收到平台原生事件时,通过 `EventConverter.target2yiri()` 转换后,调用所有匹配的回调
|
||||||
|
|
||||||
|
## 7. 从现有单文件适配器迁移
|
||||||
|
|
||||||
|
### 7.1 迁移模式
|
||||||
|
|
||||||
|
以 Telegram 为例,从 `sources/telegram.py`(445 行)拆分:
|
||||||
|
|
||||||
|
| 原代码位置 | → 新文件 |
|
||||||
|
|-----------|----------|
|
||||||
|
| `TelegramMessageConverter` 类 | `telegram/message_converter.py` |
|
||||||
|
| `TelegramEventConverter` 类 | `telegram/event_converter.py`(扩展,支持更多事件) |
|
||||||
|
| `TelegramAdapter.__init__` / `run_async` / `kill` / `register_listener` | `telegram/adapter.py` |
|
||||||
|
| `TelegramAdapter.send_message` / `reply_message` / `reply_message_chunk` | `telegram/adapter.py`(消息方法保留在主类)+ `telegram/api_impl.py`(新增 API) |
|
||||||
|
| 新增代码 | `telegram/api_impl.py`(edit_message, delete_message, get_group_info 等) |
|
||||||
|
| 新增代码 | `telegram/platform_api.py`(pin_message, unpin_message 等的映射) |
|
||||||
|
| `telegram.yaml` | `telegram/manifest.yaml`(扩展 supported_events/apis) |
|
||||||
|
|
||||||
|
### 7.2 迁移顺序建议
|
||||||
|
|
||||||
|
1. **Telegram** — 功能最完整的适配器之一,适合作为模板
|
||||||
|
2. **Discord** — 第二个迁移,验证模式的通用性
|
||||||
|
3. **AioCQHTTP (OneBot)** — 国内最常用,确保兼容
|
||||||
|
4. **其他适配器** — 按使用频率排序
|
||||||
|
|
||||||
|
### 7.3 渐进式迁移
|
||||||
|
|
||||||
|
不需要一次性迁移所有适配器。可以采用渐进策略:
|
||||||
|
|
||||||
|
1. 先在 `adapters/` 下建立新适配器
|
||||||
|
2. `Blueprint` 同时扫描 `sources/` 和 `adapters/` 两个目录
|
||||||
|
3. 旧适配器在 `sources/` 中继续工作
|
||||||
|
4. 逐个迁移到新结构
|
||||||
|
5. 全部迁移完成后移除 `sources/` 目录
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 过渡期的 Blueprint
|
||||||
|
kind: Blueprint
|
||||||
|
spec:
|
||||||
|
components:
|
||||||
|
MessagePlatformAdapter:
|
||||||
|
fromDirs:
|
||||||
|
- path: pkg/platform/sources/ # 旧路径(尚未迁移的适配器)
|
||||||
|
- path: pkg/platform/adapters/ # 新路径(已迁移的适配器)
|
||||||
|
```
|
||||||
743
docs/event-based-agents/04-event-routing.md
Normal file
743
docs/event-based-agents/04-event-routing.md
Normal file
@@ -0,0 +1,743 @@
|
|||||||
|
# 事件路由与编排
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
事件路由是 EBA 架构的核心机制:事件从适配器产生后,经由 EventBus 进入 EventRouter,由 EventRouter 根据 Bot 的配置将事件分发到对应的处理器(Handler)。
|
||||||
|
|
||||||
|
**配置方式**:用户在 WebUI 的 Bot 管理页面通过可视化编排面板管理事件处理器配置,配置数据存储在数据库的 Bot 表 `event_handlers` JSON 字段中。
|
||||||
|
|
||||||
|
## 2. 数据模型
|
||||||
|
|
||||||
|
### 2.1 Bot 实体扩展
|
||||||
|
|
||||||
|
在 `bots` 表新增 `event_handlers` 字段:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Bot(Base):
|
||||||
|
__tablename__ = "bots"
|
||||||
|
|
||||||
|
uuid: str # 主键
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
adapter: str
|
||||||
|
adapter_config: dict # JSON
|
||||||
|
enable: bool
|
||||||
|
|
||||||
|
# 新增
|
||||||
|
event_handlers: list # JSON — 事件处理器配置列表
|
||||||
|
|
||||||
|
# 保留(过渡期后弃用)
|
||||||
|
use_pipeline_name: str # deprecated
|
||||||
|
use_pipeline_uuid: str # deprecated
|
||||||
|
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 EventHandler 配置结构
|
||||||
|
|
||||||
|
`event_handlers` 字段存储一个 JSON 数组,每个元素定义一条事件路由规则:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EventHandlerConfig(pydantic.BaseModel):
|
||||||
|
"""单条事件处理器配置"""
|
||||||
|
|
||||||
|
event_type: str
|
||||||
|
"""匹配的事件类型
|
||||||
|
|
||||||
|
支持精确匹配和通配符:
|
||||||
|
- "message.received" — 精确匹配
|
||||||
|
- "message.*" — 匹配 message 命名空间下所有事件
|
||||||
|
- "group.*" — 匹配 group 命名空间下所有事件
|
||||||
|
- "*" — 匹配所有事件(兜底)
|
||||||
|
"""
|
||||||
|
|
||||||
|
handler_type: str
|
||||||
|
"""处理器类型: "pipeline" | "agent" | "webhook" | "plugin" """
|
||||||
|
|
||||||
|
handler_config: dict = {}
|
||||||
|
"""处理器的具体配置,结构取决于 handler_type"""
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
"""是否启用此规则"""
|
||||||
|
|
||||||
|
priority: int = 0
|
||||||
|
"""优先级,数字越大越先匹配(同一事件类型有多条规则时)"""
|
||||||
|
|
||||||
|
description: str = ""
|
||||||
|
"""规则描述(供 WebUI 显示)"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 各 Handler 类型的 handler_config 结构
|
||||||
|
|
||||||
|
#### pipeline
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"handler_type": "pipeline",
|
||||||
|
"handler_config": {
|
||||||
|
"pipeline_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
将事件作为消息事件传入现有 Pipeline 流水线。仅适用于 `message.received` 事件。
|
||||||
|
|
||||||
|
#### agent
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"handler_type": "agent",
|
||||||
|
"handler_config": {
|
||||||
|
"runner": "local-agent",
|
||||||
|
"runner_config": {
|
||||||
|
"model_uuid": "...",
|
||||||
|
"prompt": "你是一个群组助理,请处理以下事件:{event_summary}",
|
||||||
|
"tools_enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"handler_type": "agent",
|
||||||
|
"handler_config": {
|
||||||
|
"runner": "dify-service-api",
|
||||||
|
"runner_config": {
|
||||||
|
"base_url": "https://api.dify.ai/v1",
|
||||||
|
"api_key": "...",
|
||||||
|
"app_type": "agent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
直接调用 RequestRunner 处理事件。可用的 runner 包括:
|
||||||
|
- `local-agent` — 内置 LLM Agent
|
||||||
|
- `dify-service-api` — Dify 平台
|
||||||
|
- `n8n-service-api` — n8n 工作流
|
||||||
|
- `coze-api` — Coze (扣子)
|
||||||
|
- `dashscope-app-api` — 阿里百炼
|
||||||
|
- `langflow-api` — Langflow
|
||||||
|
- `tbox-app-api` — 蚂蚁 Tbox
|
||||||
|
|
||||||
|
Agent 处理器不经过 Pipeline 的多 Stage 流程,而是直接构建上下文并调用 Runner。适用于所有事件类型。
|
||||||
|
|
||||||
|
**Agent Handler 与 Pipeline 的关系**:
|
||||||
|
- Pipeline 是完整的多 Stage 处理链(PreProcessor → MessageProcessor(内含Runner) → PostProcessor → ...),适合复杂消息处理
|
||||||
|
- Agent Handler 是轻量级的,直接调用 Runner,跳过 PreProcessor/PostProcessor 等阶段
|
||||||
|
- Pipeline 内部的 AI Stage 仍然使用 Runner,所以 Runner 本身被两种 Handler 共享
|
||||||
|
- 用户可以根据场景选择:消息处理用 Pipeline(更多控制),其他事件用 Agent(更直接)
|
||||||
|
|
||||||
|
#### webhook
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"handler_type": "webhook",
|
||||||
|
"handler_config": {
|
||||||
|
"url": "https://example.com/webhook/langbot-events",
|
||||||
|
"method": "POST",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer xxx"
|
||||||
|
},
|
||||||
|
"timeout": 30,
|
||||||
|
"retry_count": 3,
|
||||||
|
"retry_interval": 5,
|
||||||
|
"response_actions": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
将事件序列化为 JSON POST 到外部 URL。支持的特性:
|
||||||
|
- **认证**:通过 headers 配置(Bearer Token、API Key 等)
|
||||||
|
- **重试**:配置重试次数和间隔
|
||||||
|
- **响应动作**:如果 `response_actions` 为 true,解析响应 JSON 中的 `actions` 字段并执行(如发送消息、同意好友请求等)
|
||||||
|
|
||||||
|
Webhook 请求体格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": {
|
||||||
|
"type": "group.member_joined",
|
||||||
|
"timestamp": 1700000000.0,
|
||||||
|
"bot_uuid": "...",
|
||||||
|
"adapter_name": "telegram",
|
||||||
|
"group": { "id": "...", "name": "..." },
|
||||||
|
"member": { "id": "...", "nickname": "..." }
|
||||||
|
},
|
||||||
|
"bot": {
|
||||||
|
"uuid": "...",
|
||||||
|
"name": "...",
|
||||||
|
"adapter": "telegram"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应体格式(当 `response_actions` 为 true 时):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "send_message",
|
||||||
|
"params": {
|
||||||
|
"target_type": "group",
|
||||||
|
"target_id": "123456",
|
||||||
|
"message": [{ "type": "Plain", "text": "欢迎新成员!" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "call_platform_api",
|
||||||
|
"params": {
|
||||||
|
"action": "pin_message",
|
||||||
|
"params": { "chat_id": "123456", "message_id": "789" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### plugin
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"handler_type": "plugin",
|
||||||
|
"handler_config": {
|
||||||
|
"plugin_filter": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
将事件分发给插件的 EventListener 处理。
|
||||||
|
|
||||||
|
- `plugin_filter`:可选的插件名过滤列表,为空表示分发给所有插件
|
||||||
|
- 沿用现有的插件事件分发机制(按优先级遍历插件,支持 `prevent_postorder`)
|
||||||
|
|
||||||
|
### 2.4 完整配置示例
|
||||||
|
|
||||||
|
一个 Bot 的 `event_handlers` 配置示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event_type": "message.received",
|
||||||
|
"handler_type": "pipeline",
|
||||||
|
"handler_config": {
|
||||||
|
"pipeline_uuid": "default-pipeline-uuid"
|
||||||
|
},
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 10,
|
||||||
|
"description": "消息事件使用默认流水线处理"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event_type": "group.member_joined",
|
||||||
|
"handler_type": "agent",
|
||||||
|
"handler_config": {
|
||||||
|
"runner": "local-agent",
|
||||||
|
"runner_config": {
|
||||||
|
"model_uuid": "gpt-4o-mini",
|
||||||
|
"prompt": "有新成员 {member_name} 加入了群组 {group_name},请生成一条欢迎消息。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 0,
|
||||||
|
"description": "新成员入群时用 AI 生成欢迎消息"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event_type": "friend.request_received",
|
||||||
|
"handler_type": "webhook",
|
||||||
|
"handler_config": {
|
||||||
|
"url": "https://my-server.com/api/friend-request",
|
||||||
|
"response_actions": true
|
||||||
|
},
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 0,
|
||||||
|
"description": "好友请求转发到自建服务处理"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event_type": "*",
|
||||||
|
"handler_type": "plugin",
|
||||||
|
"handler_config": {},
|
||||||
|
"enabled": true,
|
||||||
|
"priority": -100,
|
||||||
|
"description": "所有事件兜底发给插件处理"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. EventBus 设计
|
||||||
|
|
||||||
|
EventBus 是事件的中转站,接收来自各个 RuntimeBot 的事件,交由 EventRouter 处理。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EventBus:
|
||||||
|
"""事件总线"""
|
||||||
|
|
||||||
|
def __init__(self, ap: Application):
|
||||||
|
self.ap = ap
|
||||||
|
self.event_router = EventRouter(ap)
|
||||||
|
|
||||||
|
async def emit(
|
||||||
|
self,
|
||||||
|
event: Event,
|
||||||
|
adapter: AbstractPlatformAdapter,
|
||||||
|
):
|
||||||
|
"""接收并分发事件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: 统一事件对象
|
||||||
|
adapter: 产生此事件的适配器实例
|
||||||
|
"""
|
||||||
|
# 1. 全局事件日志
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f"EventBus: {event.type} from bot {event.bot_uuid}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 交由 EventRouter 路由处理
|
||||||
|
await self.event_router.route(event, adapter)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. EventRouter 设计
|
||||||
|
|
||||||
|
EventRouter 是事件路由引擎,根据 Bot 的 `event_handlers` 配置决定事件的处理方式。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EventRouter:
|
||||||
|
"""事件路由引擎"""
|
||||||
|
|
||||||
|
def __init__(self, ap: Application):
|
||||||
|
self.ap = ap
|
||||||
|
self.handlers: dict[str, AbstractEventHandler] = {
|
||||||
|
"pipeline": PipelineHandler(ap),
|
||||||
|
"agent": AgentHandler(ap),
|
||||||
|
"webhook": WebhookHandler(ap),
|
||||||
|
"plugin": PluginHandler(ap),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def route(
|
||||||
|
self,
|
||||||
|
event: Event,
|
||||||
|
adapter: AbstractPlatformAdapter,
|
||||||
|
):
|
||||||
|
"""路由事件到对应处理器"""
|
||||||
|
|
||||||
|
# 1. 获取 Bot 配置
|
||||||
|
bot = await self.ap.platform_mgr.get_bot_by_uuid(event.bot_uuid)
|
||||||
|
if not bot:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 获取事件处理器配置
|
||||||
|
event_handlers = bot.bot_entity.event_handlers or []
|
||||||
|
|
||||||
|
# 3. 匹配规则(按 priority 降序排列)
|
||||||
|
matched_handlers = self._match_handlers(event.type, event_handlers)
|
||||||
|
|
||||||
|
if not matched_handlers:
|
||||||
|
# 未匹配到任何规则 → 默认交给插件处理(向后兼容)
|
||||||
|
await self.handlers["plugin"].handle(event, adapter, {})
|
||||||
|
return
|
||||||
|
|
||||||
|
# 4. 执行第一个匹配的 Handler
|
||||||
|
# (未来可扩展为多个 Handler 串行/并行执行)
|
||||||
|
handler_config = matched_handlers[0]
|
||||||
|
handler = self.handlers.get(handler_config.handler_type)
|
||||||
|
|
||||||
|
if handler:
|
||||||
|
await handler.handle(event, adapter, handler_config.handler_config)
|
||||||
|
else:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f"Unknown handler type: {handler_config.handler_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _match_handlers(
|
||||||
|
self,
|
||||||
|
event_type: str,
|
||||||
|
handlers: list[EventHandlerConfig],
|
||||||
|
) -> list[EventHandlerConfig]:
|
||||||
|
"""匹配事件类型到处理器配置
|
||||||
|
|
||||||
|
匹配规则:
|
||||||
|
1. 精确匹配:event_type == handler.event_type
|
||||||
|
2. 命名空间通配:handler.event_type 为 "message.*" 时匹配所有 "message.xxx"
|
||||||
|
3. 全局通配:handler.event_type 为 "*" 时匹配所有事件
|
||||||
|
4. 按 priority 降序排列
|
||||||
|
5. 只返回 enabled=True 的规则
|
||||||
|
"""
|
||||||
|
matched = []
|
||||||
|
for handler in handlers:
|
||||||
|
if not handler.enabled:
|
||||||
|
continue
|
||||||
|
if self._event_type_matches(event_type, handler.event_type):
|
||||||
|
matched.append(handler)
|
||||||
|
|
||||||
|
matched.sort(key=lambda h: h.priority, reverse=True)
|
||||||
|
return matched
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _event_type_matches(event_type: str, pattern: str) -> bool:
|
||||||
|
"""判断事件类型是否匹配模式"""
|
||||||
|
if pattern == "*":
|
||||||
|
return True
|
||||||
|
if pattern == event_type:
|
||||||
|
return True
|
||||||
|
if pattern.endswith(".*"):
|
||||||
|
namespace = pattern[:-2]
|
||||||
|
return event_type.startswith(namespace + ".")
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 事件处理器(Handler)实现
|
||||||
|
|
||||||
|
### 5.1 Handler 基类
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AbstractEventHandler(abc.ABC):
|
||||||
|
"""事件处理器基类"""
|
||||||
|
|
||||||
|
def __init__(self, ap: Application):
|
||||||
|
self.ap = ap
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def handle(
|
||||||
|
self,
|
||||||
|
event: Event,
|
||||||
|
adapter: AbstractPlatformAdapter,
|
||||||
|
config: dict,
|
||||||
|
) -> None:
|
||||||
|
"""处理事件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: 统一事件对象
|
||||||
|
adapter: 适配器实例(用于调用平台 API 发送响应)
|
||||||
|
config: handler_config 配置
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 PipelineHandler
|
||||||
|
|
||||||
|
将消息事件注入现有 Pipeline 流水线处理。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PipelineHandler(AbstractEventHandler):
|
||||||
|
"""Pipeline 处理器 — 将事件送入现有 Pipeline 流水线"""
|
||||||
|
|
||||||
|
async def handle(self, event, adapter, config):
|
||||||
|
pipeline_uuid = config.get("pipeline_uuid")
|
||||||
|
|
||||||
|
if not isinstance(event, MessageReceivedEvent):
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f"PipelineHandler only supports MessageReceivedEvent, "
|
||||||
|
f"got {event.type}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 将 MessageReceivedEvent 转换为现有的 Query 并投入 QueryPool
|
||||||
|
# 复用现有的 MessageAggregator + QueryPool + Pipeline 机制
|
||||||
|
launcher_type = (
|
||||||
|
LauncherTypes.PERSON
|
||||||
|
if event.chat_type == ChatType.PRIVATE
|
||||||
|
else LauncherTypes.GROUP
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.ap.msg_aggregator.add_message(
|
||||||
|
bot_uuid=event.bot_uuid,
|
||||||
|
launcher_type=launcher_type,
|
||||||
|
launcher_id=event.chat_id,
|
||||||
|
sender_id=event.sender.id,
|
||||||
|
message_event=event.to_legacy_event(), # 转换为 FriendMessage/GroupMessage
|
||||||
|
message_chain=event.message_chain,
|
||||||
|
adapter=adapter,
|
||||||
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 AgentHandler
|
||||||
|
|
||||||
|
直接调用 RequestRunner 处理事件,不经过 Pipeline Stage 链。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AgentHandler(AbstractEventHandler):
|
||||||
|
"""Agent 处理器 — 直接调用 RequestRunner 处理事件"""
|
||||||
|
|
||||||
|
async def handle(self, event, adapter, config):
|
||||||
|
runner_name = config.get("runner", "local-agent")
|
||||||
|
runner_config = config.get("runner_config", {})
|
||||||
|
|
||||||
|
# 1. 查找 Runner 类
|
||||||
|
runner_cls = None
|
||||||
|
for r in preregistered_runners:
|
||||||
|
if r.name == runner_name:
|
||||||
|
runner_cls = r
|
||||||
|
break
|
||||||
|
|
||||||
|
if not runner_cls:
|
||||||
|
self.ap.logger.error(f"Runner not found: {runner_name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 构建事件上下文(将事件信息整理为 Runner 可处理的格式)
|
||||||
|
event_context = self._build_event_context(event, runner_config)
|
||||||
|
|
||||||
|
# 3. 实例化并调用 Runner
|
||||||
|
runner = runner_cls(self.ap, self._build_runner_pipeline_config(config))
|
||||||
|
|
||||||
|
response_messages = []
|
||||||
|
async for result in runner.run(event_context):
|
||||||
|
response_messages.append(result)
|
||||||
|
|
||||||
|
# 4. 发送响应(如果 Runner 产生了回复)
|
||||||
|
if response_messages and isinstance(event, MessageReceivedEvent):
|
||||||
|
# 将 Runner 输出转换为 MessageChain 并回复
|
||||||
|
reply_chain = self._build_reply_chain(response_messages)
|
||||||
|
await adapter.reply_message(event, reply_chain)
|
||||||
|
|
||||||
|
def _build_event_context(self, event, runner_config):
|
||||||
|
"""将事件构建为 Runner 可处理的上下文
|
||||||
|
|
||||||
|
对于消息事件,直接使用消息内容。
|
||||||
|
对于其他事件,根据 runner_config 中的 prompt 模板生成描述文本。
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def _build_runner_pipeline_config(self, config):
|
||||||
|
"""将 handler_config 转换为 Runner 需要的 pipeline_config 格式"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 WebhookHandler
|
||||||
|
|
||||||
|
将事件 POST 到外部 URL。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class WebhookHandler(AbstractEventHandler):
|
||||||
|
"""Webhook 处理器 — 将事件 POST 到外部 URL"""
|
||||||
|
|
||||||
|
async def handle(self, event, adapter, config):
|
||||||
|
url = config.get("url")
|
||||||
|
method = config.get("method", "POST")
|
||||||
|
headers = config.get("headers", {})
|
||||||
|
timeout = config.get("timeout", 30)
|
||||||
|
retry_count = config.get("retry_count", 3)
|
||||||
|
response_actions = config.get("response_actions", False)
|
||||||
|
|
||||||
|
# 1. 构建请求体
|
||||||
|
bot = await self.ap.platform_mgr.get_bot_by_uuid(event.bot_uuid)
|
||||||
|
payload = {
|
||||||
|
"event": event.model_dump(),
|
||||||
|
"bot": {
|
||||||
|
"uuid": bot.bot_entity.uuid,
|
||||||
|
"name": bot.bot_entity.name,
|
||||||
|
"adapter": bot.bot_entity.adapter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. 发送请求(带重试)
|
||||||
|
response = await self._send_with_retry(
|
||||||
|
url, method, headers, payload, timeout, retry_count
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 处理响应动作
|
||||||
|
if response_actions and response:
|
||||||
|
await self._execute_response_actions(
|
||||||
|
response, adapter, event
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _execute_response_actions(self, response, adapter, event):
|
||||||
|
"""执行响应中的动作列表"""
|
||||||
|
actions = response.get("actions", [])
|
||||||
|
for action in actions:
|
||||||
|
action_type = action.get("type")
|
||||||
|
params = action.get("params", {})
|
||||||
|
|
||||||
|
if action_type == "send_message":
|
||||||
|
chain = MessageChain.model_validate(params.get("message", []))
|
||||||
|
await adapter.send_message(
|
||||||
|
params["target_type"],
|
||||||
|
params["target_id"],
|
||||||
|
chain,
|
||||||
|
)
|
||||||
|
elif action_type == "reply":
|
||||||
|
chain = MessageChain.model_validate(params.get("message", []))
|
||||||
|
await adapter.reply_message(event, chain)
|
||||||
|
elif action_type == "call_platform_api":
|
||||||
|
await adapter.call_platform_api(
|
||||||
|
params["action"],
|
||||||
|
params.get("params", {}),
|
||||||
|
)
|
||||||
|
elif action_type == "approve_friend_request":
|
||||||
|
await adapter.approve_friend_request(
|
||||||
|
params["request_id"],
|
||||||
|
params.get("approve", True),
|
||||||
|
)
|
||||||
|
# ... 更多动作类型
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 PluginHandler
|
||||||
|
|
||||||
|
将事件分发给插件的 EventListener。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PluginHandler(AbstractEventHandler):
|
||||||
|
"""Plugin 处理器 — 分发给插件 EventListener"""
|
||||||
|
|
||||||
|
async def handle(self, event, adapter, config):
|
||||||
|
plugin_filter = config.get("plugin_filter", [])
|
||||||
|
|
||||||
|
# 复用现有的插件事件分发机制
|
||||||
|
# 通过 plugin_connector 将事件发送给 Plugin Runtime
|
||||||
|
await self.ap.plugin_connector.emit_event(
|
||||||
|
event=event,
|
||||||
|
adapter=adapter,
|
||||||
|
plugin_filter=plugin_filter,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. use_pipeline_uuid 迁移
|
||||||
|
|
||||||
|
### 6.1 自动迁移
|
||||||
|
|
||||||
|
数据库迁移脚本将现有的 `use_pipeline_uuid` 自动转换为 `event_handlers`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 迁移逻辑
|
||||||
|
for bot in all_bots:
|
||||||
|
if bot.use_pipeline_uuid and not bot.event_handlers:
|
||||||
|
bot.event_handlers = [
|
||||||
|
{
|
||||||
|
"event_type": "message.received",
|
||||||
|
"handler_type": "pipeline",
|
||||||
|
"handler_config": {
|
||||||
|
"pipeline_uuid": bot.use_pipeline_uuid
|
||||||
|
},
|
||||||
|
"enabled": True,
|
||||||
|
"priority": 10,
|
||||||
|
"description": "Auto-migrated from use_pipeline_uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event_type": "*",
|
||||||
|
"handler_type": "plugin",
|
||||||
|
"handler_config": {},
|
||||||
|
"enabled": True,
|
||||||
|
"priority": -100,
|
||||||
|
"description": "Default plugin handler"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 过渡期兼容
|
||||||
|
|
||||||
|
在过渡期内,如果 `event_handlers` 为空且 `use_pipeline_uuid` 非空,EventRouter 自动回退到旧行为:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# EventRouter.route() 中的兼容逻辑
|
||||||
|
if not event_handlers and bot.bot_entity.use_pipeline_uuid:
|
||||||
|
# 回退:消息事件走 Pipeline,其他事件走 Plugin
|
||||||
|
if isinstance(event, MessageReceivedEvent):
|
||||||
|
await self.handlers["pipeline"].handle(
|
||||||
|
event, adapter,
|
||||||
|
{"pipeline_uuid": bot.bot_entity.use_pipeline_uuid}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.handlers["plugin"].handle(event, adapter, {})
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. WebUI 编排面板数据模型
|
||||||
|
|
||||||
|
### 7.1 交互设计概要
|
||||||
|
|
||||||
|
在 WebUI 的 Bot 管理页面,新增"事件处理器"标签页(或区域),呈现为一个**规则列表**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 事件处理器 [+ 添加规则] │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─ 规则 1 ─────────────────────────────────── [启用] [删除] ─┐ │
|
||||||
|
│ │ 事件类型: [message.received ▾] │ │
|
||||||
|
│ │ 处理器: [Pipeline ▾] │ │
|
||||||
|
│ │ Pipeline: [默认流水线 ▾] │ │
|
||||||
|
│ │ 优先级: [10] │ │
|
||||||
|
│ │ 描述: 消息事件使用默认流水线处理 │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ 规则 2 ─────────────────────────────────── [启用] [删除] ─┐ │
|
||||||
|
│ │ 事件类型: [group.member_joined ▾] │ │
|
||||||
|
│ │ 处理器: [Agent ▾] │ │
|
||||||
|
│ │ Runner: [local-agent ▾] │ │
|
||||||
|
│ │ 模型: [gpt-4o-mini ▾] │ │
|
||||||
|
│ │ Prompt: [有新成员加入...] │ │
|
||||||
|
│ │ 优先级: [0] │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ 规则 3 (兜底) ──────────────────────────── [启用] [删除] ─┐ │
|
||||||
|
│ │ 事件类型: [* ▾] │ │
|
||||||
|
│ │ 处理器: [Plugin ▾] │ │
|
||||||
|
│ │ 优先级: [-100] │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 前端数据结构
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface EventHandlerRule {
|
||||||
|
event_type: string; // 下拉选择,选项从适配器 manifest 的 supported_events 获取
|
||||||
|
handler_type: string; // "pipeline" | "agent" | "webhook" | "plugin"
|
||||||
|
handler_config: Record<string, any>; // 根据 handler_type 动态渲染不同的配置表单
|
||||||
|
enabled: boolean;
|
||||||
|
priority: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bot 编辑接口扩展
|
||||||
|
interface BotConfig {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
adapter: string;
|
||||||
|
adapter_config: Record<string, any>;
|
||||||
|
enable: boolean;
|
||||||
|
event_handlers: EventHandlerRule[]; // 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 事件类型下拉选项
|
||||||
|
|
||||||
|
从 Bot 关联的适配器 manifest 中获取 `supported_events`,加上通配符选项:
|
||||||
|
|
||||||
|
```
|
||||||
|
- message.received
|
||||||
|
- message.edited
|
||||||
|
- message.deleted
|
||||||
|
- message.reaction
|
||||||
|
- feedback.received
|
||||||
|
- group.member_joined
|
||||||
|
- group.member_left
|
||||||
|
- group.member_banned
|
||||||
|
- group.info_updated
|
||||||
|
- friend.request_received
|
||||||
|
- friend.added
|
||||||
|
- bot.invited_to_group
|
||||||
|
- bot.removed_from_group
|
||||||
|
- bot.muted
|
||||||
|
- bot.unmuted
|
||||||
|
- platform.specific
|
||||||
|
─────────────────
|
||||||
|
- message.* (所有消息事件)
|
||||||
|
- feedback.* (所有反馈事件)
|
||||||
|
- group.* (所有群组事件)
|
||||||
|
- friend.* (所有好友事件)
|
||||||
|
- bot.* (所有 Bot 事件)
|
||||||
|
- * (所有事件)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 HTTP API
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/bots/{uuid}/event-handlers 获取 Bot 的事件处理器配置
|
||||||
|
PUT /api/v1/bots/{uuid}/event-handlers 更新 Bot 的事件处理器配置
|
||||||
|
GET /api/v1/adapters/{name}/supported-events 获取适配器支持的事件类型
|
||||||
|
GET /api/v1/adapters/{name}/supported-apis 获取适配器支持的 API
|
||||||
|
```
|
||||||
738
docs/event-based-agents/05-plugin-sdk.md
Normal file
738
docs/event-based-agents/05-plugin-sdk.md
Normal file
@@ -0,0 +1,738 @@
|
|||||||
|
# 插件 SDK 改造
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
插件 SDK 需要配合 EBA 架构进行以下改造:
|
||||||
|
|
||||||
|
1. **新事件类型**:将所有通用事件暴露给插件
|
||||||
|
2. **新 API**:将新增的平台 API 通过 `LangBotAPIProxy` 暴露给插件
|
||||||
|
3. **兼容层**:保证现有插件零修改运行
|
||||||
|
4. **通信协议扩展**:新增 action 枚举支持新 API
|
||||||
|
|
||||||
|
## 2. 新事件类型暴露
|
||||||
|
|
||||||
|
### 2.1 插件事件模型扩展
|
||||||
|
|
||||||
|
当前插件 SDK 的事件模型(`api/entities/events.py`)只有消息相关事件。需要新增所有通用事件的插件级包装:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# api/entities/events.py — 新增事件
|
||||||
|
|
||||||
|
# ---- 消息事件(扩展) ----
|
||||||
|
|
||||||
|
class MessageEditedReceived(BaseEventModel):
|
||||||
|
"""消息被编辑事件"""
|
||||||
|
launcher_type: str
|
||||||
|
launcher_id: typing.Union[int, str]
|
||||||
|
message_id: typing.Union[int, str]
|
||||||
|
editor_id: typing.Union[int, str]
|
||||||
|
new_content: MessageChain
|
||||||
|
chat_type: str # "private" | "group"
|
||||||
|
|
||||||
|
class MessageDeletedReceived(BaseEventModel):
|
||||||
|
"""消息被删除/撤回事件"""
|
||||||
|
launcher_type: str
|
||||||
|
launcher_id: typing.Union[int, str]
|
||||||
|
message_id: typing.Union[int, str]
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
chat_type: str
|
||||||
|
|
||||||
|
class MessageReactionReceived(BaseEventModel):
|
||||||
|
"""消息表情回应事件"""
|
||||||
|
launcher_type: str
|
||||||
|
launcher_id: typing.Union[int, str]
|
||||||
|
message_id: typing.Union[int, str]
|
||||||
|
user_id: typing.Union[int, str]
|
||||||
|
reaction: str
|
||||||
|
is_add: bool
|
||||||
|
|
||||||
|
# ---- 用户反馈事件 ----
|
||||||
|
|
||||||
|
class FeedbackReceived(BaseEventModel):
|
||||||
|
"""用户对 Bot 回复提交反馈"""
|
||||||
|
feedback_id: str
|
||||||
|
feedback_type: int # 1=like, 2=dislike, 3=cancel/remove feedback
|
||||||
|
feedback_content: typing.Optional[str] = None
|
||||||
|
inaccurate_reasons: typing.Optional[list[str]] = None
|
||||||
|
user_id: typing.Optional[str] = None
|
||||||
|
session_id: typing.Optional[str] = None
|
||||||
|
message_id: typing.Optional[str] = None
|
||||||
|
stream_id: typing.Optional[str] = None
|
||||||
|
|
||||||
|
# ---- 群组事件 ----
|
||||||
|
|
||||||
|
class GroupMemberJoined(BaseEventModel):
|
||||||
|
"""新成员加入群组"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
group_name: str
|
||||||
|
member_id: typing.Union[int, str]
|
||||||
|
member_name: str
|
||||||
|
inviter_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
join_type: typing.Optional[str] = None
|
||||||
|
|
||||||
|
class GroupMemberLeft(BaseEventModel):
|
||||||
|
"""成员离开群组"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
group_name: str
|
||||||
|
member_id: typing.Union[int, str]
|
||||||
|
member_name: str
|
||||||
|
is_kicked: bool = False
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
|
||||||
|
class GroupMemberBanned(BaseEventModel):
|
||||||
|
"""成员被禁言"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
member_id: typing.Union[int, str]
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
duration: typing.Optional[int] = None
|
||||||
|
|
||||||
|
class GroupMemberUnbanned(BaseEventModel):
|
||||||
|
"""成员被解除禁言"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
member_id: typing.Union[int, str]
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
|
||||||
|
class GroupInfoUpdated(BaseEventModel):
|
||||||
|
"""群组信息被修改"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
group_name: str
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
changed_fields: list[str] = []
|
||||||
|
|
||||||
|
# ---- 好友事件 ----
|
||||||
|
|
||||||
|
class FriendRequestReceived(BaseEventModel):
|
||||||
|
"""收到好友请求"""
|
||||||
|
request_id: typing.Union[int, str]
|
||||||
|
user_id: typing.Union[int, str]
|
||||||
|
user_name: str
|
||||||
|
message: typing.Optional[str] = None
|
||||||
|
|
||||||
|
class FriendAdded(BaseEventModel):
|
||||||
|
"""成功添加好友"""
|
||||||
|
user_id: typing.Union[int, str]
|
||||||
|
user_name: str
|
||||||
|
|
||||||
|
class FriendRemoved(BaseEventModel):
|
||||||
|
"""好友被移除"""
|
||||||
|
user_id: typing.Union[int, str]
|
||||||
|
user_name: str
|
||||||
|
|
||||||
|
# ---- Bot 状态事件 ----
|
||||||
|
|
||||||
|
class BotInvitedToGroup(BaseEventModel):
|
||||||
|
"""Bot 被邀请加入群组"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
group_name: str
|
||||||
|
inviter_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
request_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
|
||||||
|
class BotRemovedFromGroup(BaseEventModel):
|
||||||
|
"""Bot 被移出群组"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
group_name: str
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
|
||||||
|
class BotMuted(BaseEventModel):
|
||||||
|
"""Bot 被禁言"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
duration: typing.Optional[int] = None
|
||||||
|
|
||||||
|
class BotUnmuted(BaseEventModel):
|
||||||
|
"""Bot 被解除禁言"""
|
||||||
|
group_id: typing.Union[int, str]
|
||||||
|
operator_id: typing.Optional[typing.Union[int, str]] = None
|
||||||
|
|
||||||
|
# ---- 平台特有事件 ----
|
||||||
|
|
||||||
|
class PlatformSpecificEventReceived(BaseEventModel):
|
||||||
|
"""平台特有事件"""
|
||||||
|
adapter_name: str
|
||||||
|
action: str
|
||||||
|
data: dict = {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 EventListener 注册方式
|
||||||
|
|
||||||
|
插件的 EventListener 继续使用 `@self.handler(EventType)` 装饰器注册,只是可以注册的事件类型大幅增加:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyEventListener(EventListener):
|
||||||
|
def __init__(self, host):
|
||||||
|
super().__init__(host)
|
||||||
|
|
||||||
|
# 现有方式(继续工作)
|
||||||
|
@self.handler(PersonNormalMessageReceived)
|
||||||
|
async def on_person_message(ctx: EventContext):
|
||||||
|
...
|
||||||
|
|
||||||
|
# 新事件类型
|
||||||
|
@self.handler(GroupMemberJoined)
|
||||||
|
async def on_member_joined(ctx: EventContext):
|
||||||
|
group_name = ctx.event.group_name
|
||||||
|
member_name = ctx.event.member_name
|
||||||
|
await ctx.reply(MessageChain([
|
||||||
|
Plain(f"欢迎 {member_name} 加入 {group_name}!")
|
||||||
|
]))
|
||||||
|
|
||||||
|
@self.handler(FriendRequestReceived)
|
||||||
|
async def on_friend_request(ctx: EventContext):
|
||||||
|
# 自动通过好友请求
|
||||||
|
await ctx.approve_friend_request(
|
||||||
|
ctx.event.request_id, approve=True
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.handler(FeedbackReceived)
|
||||||
|
async def on_feedback(ctx: EventContext):
|
||||||
|
if ctx.event.feedback_type == 2:
|
||||||
|
await self.log_warning(
|
||||||
|
f"用户点踩了回复: {ctx.event.feedback_content or ''}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.handler(PlatformSpecificEventReceived)
|
||||||
|
async def on_platform_event(ctx: EventContext):
|
||||||
|
if ctx.event.adapter_name == "telegram" and ctx.event.action == "chat_join_request":
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 新 API 暴露
|
||||||
|
|
||||||
|
### 3.1 LangBotAPIProxy 扩展
|
||||||
|
|
||||||
|
在 `LangBotAPIProxy` 中新增以下方法,插件通过 `self.xxx()` 调用(在 BasePlugin 中继承):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class LangBotAPIProxy:
|
||||||
|
# ---- 现有方法(保留) ----
|
||||||
|
# get_langbot_version, get_bots, get_bot_info,
|
||||||
|
# send_message, invoke_llm, get/set/delete_plugin_storage, ...
|
||||||
|
|
||||||
|
# ---- 新增消息 API ----
|
||||||
|
|
||||||
|
async def edit_message(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
chat_type: str,
|
||||||
|
chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
new_content: MessageChain,
|
||||||
|
) -> None:
|
||||||
|
"""编辑已发送的消息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def delete_message(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
chat_type: str,
|
||||||
|
chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""删除/撤回消息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def forward_message(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
from_chat_type: str,
|
||||||
|
from_chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
to_chat_type: str,
|
||||||
|
to_chat_id: typing.Union[int, str],
|
||||||
|
) -> dict:
|
||||||
|
"""转发消息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_message(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
chat_type: str,
|
||||||
|
chat_id: typing.Union[int, str],
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
) -> dict:
|
||||||
|
"""获取指定消息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# ---- 新增群组 API ----
|
||||||
|
|
||||||
|
async def get_group_info(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
) -> dict:
|
||||||
|
"""获取群组信息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_group_list(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""获取 Bot 加入的群组列表"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_group_member_list(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""获取群成员列表"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_group_member_info(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> dict:
|
||||||
|
"""获取指定群成员信息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def mute_member(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
duration: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""禁言群成员"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def unmute_member(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""解除禁言"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def kick_member(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
group_id: typing.Union[int, str],
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""踢出群成员"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# ---- 新增用户 API ----
|
||||||
|
|
||||||
|
async def get_user_info(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
user_id: typing.Union[int, str],
|
||||||
|
) -> dict:
|
||||||
|
"""获取用户信息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_friend_list(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""获取好友列表"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def approve_friend_request(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
request_id: typing.Union[int, str],
|
||||||
|
approve: bool = True,
|
||||||
|
remark: typing.Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""处理好友请求"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def approve_group_invite(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
request_id: typing.Union[int, str],
|
||||||
|
approve: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""处理入群邀请"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# ---- 新增透传 API ----
|
||||||
|
|
||||||
|
async def call_platform_api(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
action: str,
|
||||||
|
params: dict = {},
|
||||||
|
) -> dict:
|
||||||
|
"""调用适配器特有 API
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Telegram: pin 消息
|
||||||
|
result = await self.call_platform_api(
|
||||||
|
bot_uuid, "pin_message",
|
||||||
|
{"chat_id": 123456, "message_id": 789}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Discord: 创建频道
|
||||||
|
result = await self.call_platform_api(
|
||||||
|
bot_uuid, "create_channel",
|
||||||
|
{"guild_id": "...", "name": "new-channel"}
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# ---- 新增能力查询 API ----
|
||||||
|
|
||||||
|
async def get_supported_events(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""获取指定 Bot 的适配器支持的事件类型"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_supported_apis(
|
||||||
|
self,
|
||||||
|
bot_uuid: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""获取指定 Bot 的适配器支持的 API"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 QueryBasedAPIProxy 扩展
|
||||||
|
|
||||||
|
在事件处理上下文中(EventContext),通过 `QueryBasedAPIProxy` 新增便捷方法:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class QueryBasedAPIProxy:
|
||||||
|
# ---- 现有方法(保留) ----
|
||||||
|
# reply, get_bot_uuid, set_query_var, get_query_var,
|
||||||
|
# create_new_conversation, ...
|
||||||
|
|
||||||
|
# ---- 新增便捷方法 ----
|
||||||
|
|
||||||
|
async def edit_message(
|
||||||
|
self,
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
new_content: MessageChain,
|
||||||
|
) -> None:
|
||||||
|
"""在当前会话中编辑消息(自动使用当前 bot_uuid 和 chat 信息)"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def delete_message(
|
||||||
|
self,
|
||||||
|
message_id: typing.Union[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""在当前会话中删除消息"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def approve_friend_request(
|
||||||
|
self,
|
||||||
|
request_id: typing.Union[int, str],
|
||||||
|
approve: bool = True,
|
||||||
|
remark: typing.Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""处理好友请求(上下文中自动获取 bot_uuid)"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def approve_group_invite(
|
||||||
|
self,
|
||||||
|
request_id: typing.Union[int, str],
|
||||||
|
approve: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""处理入群邀请"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_group_info(self) -> dict:
|
||||||
|
"""获取当前群组信息(仅群聊事件中可用)"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_group_member_list(self) -> list[dict]:
|
||||||
|
"""获取当前群组成员列表(仅群聊事件中可用)"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def call_platform_api(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
params: dict = {},
|
||||||
|
) -> dict:
|
||||||
|
"""调用平台特有 API(自动使用当前 bot_uuid)"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 兼容层设计
|
||||||
|
|
||||||
|
### 4.1 事件兼容层
|
||||||
|
|
||||||
|
当 PluginHandler 将新的 `MessageReceivedEvent` 分发给插件时,需要同时生成旧格式事件:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PluginEventCompatLayer:
|
||||||
|
"""插件事件兼容层
|
||||||
|
|
||||||
|
将新的统一事件转换为旧的插件事件格式,
|
||||||
|
确保监听旧事件类型的插件仍能正常工作。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def convert_to_legacy_events(
|
||||||
|
event: Event,
|
||||||
|
) -> list[BaseEventModel]:
|
||||||
|
"""将统一事件转换为旧插件事件列表
|
||||||
|
|
||||||
|
一个统一事件可能生成多个旧插件事件。
|
||||||
|
例如 MessageReceivedEvent 会同时生成:
|
||||||
|
- PersonMessageReceived / GroupMessageReceived(总是生成)
|
||||||
|
- PersonNormalMessageReceived / GroupNormalMessageReceived(非命令时)
|
||||||
|
- PersonCommandSent / GroupCommandSent(命令时)
|
||||||
|
"""
|
||||||
|
legacy_events = []
|
||||||
|
|
||||||
|
if isinstance(event, MessageReceivedEvent):
|
||||||
|
if event.chat_type == ChatType.PRIVATE:
|
||||||
|
legacy_events.append(
|
||||||
|
PersonMessageReceived(
|
||||||
|
launcher_type="person",
|
||||||
|
launcher_id=event.chat_id,
|
||||||
|
sender_id=event.sender.id,
|
||||||
|
message_event=event.to_legacy_friend_message(),
|
||||||
|
message_chain=event.message_chain,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 命令检测后还会生成 PersonNormalMessageReceived
|
||||||
|
# 或 PersonCommandSent,在 Pipeline 阶段处理
|
||||||
|
elif event.chat_type == ChatType.GROUP:
|
||||||
|
legacy_events.append(
|
||||||
|
GroupMessageReceived(
|
||||||
|
launcher_type="group",
|
||||||
|
launcher_id=event.chat_id,
|
||||||
|
sender_id=event.sender.id,
|
||||||
|
message_event=event.to_legacy_group_message(),
|
||||||
|
message_chain=event.message_chain,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 新事件类型没有旧的对应物,不生成兼容事件
|
||||||
|
# 只有监听了新事件类型的插件才会收到
|
||||||
|
|
||||||
|
return legacy_events
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 分发流程
|
||||||
|
|
||||||
|
```
|
||||||
|
统一事件 (MessageReceivedEvent)
|
||||||
|
│
|
||||||
|
├─→ 转换为旧格式 (PersonMessageReceived / GroupMessageReceived)
|
||||||
|
│ └─→ 分发给监听旧事件类型的插件 EventListener
|
||||||
|
│
|
||||||
|
└─→ 直接分发为新格式 (MessageReceivedEvent → 对应的插件事件)
|
||||||
|
└─→ 分发给监听新事件类型的插件 EventListener
|
||||||
|
```
|
||||||
|
|
||||||
|
插件 Runtime 在分发事件时检查每个 EventListener 注册的事件类型:
|
||||||
|
- 如果注册的是旧类型(`PersonMessageReceived` 等),发送兼容层生成的旧格式事件
|
||||||
|
- 如果注册的是新类型(`GroupMemberJoined` 等),发送新格式事件
|
||||||
|
- 两者可以共存,同一个插件可以同时监听新旧类型
|
||||||
|
|
||||||
|
### 4.3 API 兼容层
|
||||||
|
|
||||||
|
现有插件使用的 API 不受影响:
|
||||||
|
|
||||||
|
| 现有 API | 新架构行为 |
|
||||||
|
|---------|----------|
|
||||||
|
| `self.send_message(bot_uuid, target_type, target_id, message_chain)` | 不变,直接调用适配器的 `send_message` |
|
||||||
|
| `ctx.reply(message_chain, quote_origin)` | 不变,在 MessageReceivedEvent 上下文中调用适配器的 `reply_message` |
|
||||||
|
| `self.get_bots()` | 不变 |
|
||||||
|
| `self.get_bot_info(bot_uuid)` | 不变 |
|
||||||
|
|
||||||
|
新 API 只是额外新增的方法,不影响现有方法。
|
||||||
|
|
||||||
|
## 5. 通信协议扩展
|
||||||
|
|
||||||
|
### 5.1 新增 Action 枚举
|
||||||
|
|
||||||
|
在 `entities/io/actions/enums.py` 中新增 action:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PluginToRuntimeAction(str, Enum):
|
||||||
|
# ---- 现有 actions(保留) ----
|
||||||
|
REGISTER_PLUGIN = "register_plugin"
|
||||||
|
REPLY = "reply"
|
||||||
|
SEND_MESSAGE = "send_message"
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# ---- 新增消息 API ----
|
||||||
|
EDIT_MESSAGE = "edit_message"
|
||||||
|
DELETE_MESSAGE = "delete_message"
|
||||||
|
FORWARD_MESSAGE = "forward_message"
|
||||||
|
GET_MESSAGE = "get_message"
|
||||||
|
|
||||||
|
# ---- 新增群组 API ----
|
||||||
|
GET_GROUP_INFO = "get_group_info"
|
||||||
|
GET_GROUP_LIST = "get_group_list"
|
||||||
|
GET_GROUP_MEMBER_LIST = "get_group_member_list"
|
||||||
|
GET_GROUP_MEMBER_INFO = "get_group_member_info"
|
||||||
|
MUTE_MEMBER = "mute_member"
|
||||||
|
UNMUTE_MEMBER = "unmute_member"
|
||||||
|
KICK_MEMBER = "kick_member"
|
||||||
|
|
||||||
|
# ---- 新增用户 API ----
|
||||||
|
GET_USER_INFO = "get_user_info"
|
||||||
|
GET_FRIEND_LIST = "get_friend_list"
|
||||||
|
APPROVE_FRIEND_REQUEST = "approve_friend_request"
|
||||||
|
APPROVE_GROUP_INVITE = "approve_group_invite"
|
||||||
|
|
||||||
|
# ---- 新增透传 API ----
|
||||||
|
CALL_PLATFORM_API = "call_platform_api"
|
||||||
|
|
||||||
|
# ---- 新增能力查询 ----
|
||||||
|
GET_SUPPORTED_EVENTS = "get_supported_events"
|
||||||
|
GET_SUPPORTED_APIS = "get_supported_apis"
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeToPluginAction(str, Enum):
|
||||||
|
# ---- 现有 actions(保留) ----
|
||||||
|
EMIT_EVENT = "emit_event"
|
||||||
|
# ...
|
||||||
|
# EMIT_EVENT 的 data 结构扩展以支持新事件类型
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 新增 Action 的请求/响应格式
|
||||||
|
|
||||||
|
以 `EDIT_MESSAGE` 为例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求 (Plugin → Runtime)
|
||||||
|
{
|
||||||
|
"action": "edit_message",
|
||||||
|
"seq_id": 12345,
|
||||||
|
"data": {
|
||||||
|
"bot_uuid": "...",
|
||||||
|
"chat_type": "group",
|
||||||
|
"chat_id": "123456",
|
||||||
|
"message_id": "789",
|
||||||
|
"new_content": [
|
||||||
|
{ "type": "Plain", "text": "edited message" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应 (Runtime → Plugin)
|
||||||
|
{
|
||||||
|
"seq_id": 12345,
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
以 `GET_GROUP_MEMBER_LIST` 为例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{
|
||||||
|
"action": "get_group_member_list",
|
||||||
|
"seq_id": 12346,
|
||||||
|
"data": {
|
||||||
|
"bot_uuid": "...",
|
||||||
|
"group_id": "123456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{
|
||||||
|
"seq_id": 12346,
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"user": { "id": "111", "nickname": "Alice" },
|
||||||
|
"group_id": "123456",
|
||||||
|
"role": "admin",
|
||||||
|
"display_name": "管理员Alice"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
以 `CALL_PLATFORM_API` 为例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 请求
|
||||||
|
{
|
||||||
|
"action": "call_platform_api",
|
||||||
|
"seq_id": 12347,
|
||||||
|
"data": {
|
||||||
|
"bot_uuid": "...",
|
||||||
|
"action": "pin_message",
|
||||||
|
"params": {
|
||||||
|
"chat_id": "123456",
|
||||||
|
"message_id": "789"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{
|
||||||
|
"seq_id": 12347,
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"result": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 LangBot 侧 Handler 实现
|
||||||
|
|
||||||
|
在 `ControlConnectionHandler`(LangBot → Runtime 侧)和 `PluginConnectionHandler`(Runtime → Plugin 侧)中新增对应的 action 处理逻辑:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# PluginConnectionHandler 中新增
|
||||||
|
async def _handle_edit_message(self, data):
|
||||||
|
bot_uuid = data["bot_uuid"]
|
||||||
|
bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||||
|
await bot.adapter.edit_message(
|
||||||
|
chat_type=data["chat_type"],
|
||||||
|
chat_id=data["chat_id"],
|
||||||
|
message_id=data["message_id"],
|
||||||
|
new_content=MessageChain.model_validate(data["new_content"]),
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def _handle_call_platform_api(self, data):
|
||||||
|
bot_uuid = data["bot_uuid"]
|
||||||
|
bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||||
|
result = await bot.adapter.call_platform_api(
|
||||||
|
action=data["action"],
|
||||||
|
params=data.get("params", {}),
|
||||||
|
)
|
||||||
|
return {"result": result}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 插件开发者迁移指南
|
||||||
|
|
||||||
|
### 6.1 无需迁移(零修改运行)
|
||||||
|
|
||||||
|
以下场景的现有插件**不需要任何修改**:
|
||||||
|
|
||||||
|
- 使用 `PersonNormalMessageReceived` / `GroupNormalMessageReceived` 监听消息
|
||||||
|
- 使用 `PersonCommandSent` / `GroupCommandSent` 处理命令
|
||||||
|
- 使用 `ctx.reply()` 回复消息
|
||||||
|
- 使用 `self.send_message()` 主动发消息
|
||||||
|
- 使用 LLM / 存储 / RAG 等现有 API
|
||||||
|
|
||||||
|
### 6.2 推荐迁移(获得新能力)
|
||||||
|
|
||||||
|
如果插件希望利用新功能,可以:
|
||||||
|
|
||||||
|
1. **监听新事件类型**:在 EventListener 中注册新事件类型的 handler
|
||||||
|
2. **使用新 API**:调用 `self.edit_message()`, `self.get_group_info()` 等
|
||||||
|
3. **使用透传 API**:调用 `self.call_platform_api()` 使用平台特有功能
|
||||||
|
|
||||||
|
### 6.3 SDK 版本号
|
||||||
|
|
||||||
|
新功能通过提升 SDK minor 版本发布:
|
||||||
|
|
||||||
|
- 现有版本:`langbot-plugin-sdk >= x.y.z`
|
||||||
|
- 新版本:`langbot-plugin-sdk >= x.(y+1).0`
|
||||||
|
|
||||||
|
插件的 `manifest.yaml` 中的 `min_sdk_version` 决定是否能使用新 API。使用旧 SDK 版本的插件在新 LangBot 上正常运行(兼容层保证),只是无法调用新 API。
|
||||||
429
docs/event-based-agents/06-migration-plan.md
Normal file
429
docs/event-based-agents/06-migration-plan.md
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
# 分阶段迁移计划
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
EBA 架构涉及 langbot-plugin-sdk、LangBot 后端、LangBot 前端、文档和示例插件等多个仓库的改动。为降低风险、保证系统稳定性,采用分阶段渐进式迁移策略。
|
||||||
|
|
||||||
|
### 1.1 阶段总览
|
||||||
|
|
||||||
|
| 阶段 | 名称 | 范围 | 依赖 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| Phase 1 | SDK 实体层 | langbot-plugin-sdk | 无 |
|
||||||
|
| Phase 2 | 适配器重构 | LangBot 后端 | Phase 1 |
|
||||||
|
| Phase 3 | 核心系统 | LangBot 后端 | Phase 2 |
|
||||||
|
| Phase 4 | 插件 SDK 集成 | langbot-plugin-sdk + LangBot | Phase 3 |
|
||||||
|
| Phase 5 | WebUI 编排面板 | LangBot 前端 | Phase 3 |
|
||||||
|
| Phase 6 | 文档与示例 | langbot-wiki + langbot-plugin-demo | Phase 4, 5 |
|
||||||
|
|
||||||
|
### 1.2 核心原则
|
||||||
|
|
||||||
|
- **每个阶段结束后系统可运行**:任何阶段完成后,现有功能不受影响
|
||||||
|
- **向后兼容贯穿全程**:旧接口在整个迁移期间保持可用
|
||||||
|
- **先 SDK 后实现**:先定义好接口和模型,再做具体实现
|
||||||
|
- **先核心适配器后边缘**:优先迁移用户量大的适配器
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Phase 1:SDK 实体层
|
||||||
|
|
||||||
|
**目标**:在 langbot-plugin-sdk 中定义新的事件体系、通用实体、API 接口和适配器基类。
|
||||||
|
|
||||||
|
**仓库**:`langbot-plugin-sdk`
|
||||||
|
|
||||||
|
### 2.1 任务清单
|
||||||
|
|
||||||
|
| # | 任务 | 文件/模块 | 说明 |
|
||||||
|
|---|------|----------|------|
|
||||||
|
| 1.1 | 定义通用事件基类层次 | `api/entities/builtin/platform/events.py` | 新增 `MessageReceivedEvent`, `MessageEditedEvent`, `GroupMemberJoinedEvent` 等,保留现有 `FriendMessage`/`GroupMessage` |
|
||||||
|
| 1.2 | 定义平台特有事件基类 | `api/entities/builtin/platform/events.py` | 新增 `PlatformSpecificEvent` |
|
||||||
|
| 1.3 | 扩展通用实体 | `api/entities/builtin/platform/entities.py` | 新增 `User`(统一 Friend/GroupMember 的基础)、`Channel` 等,保留现有实体 |
|
||||||
|
| 1.4 | 清理消息组件 | `api/entities/builtin/platform/message.py` | 将 `WeChatMiniPrograms` 等 WeChat 特有组件标记为 platform-specific,不再作为通用组件 |
|
||||||
|
| 1.5 | 定义新适配器基类 | `api/definition/abstract/platform/adapter.py` | 新增 `AbstractPlatformAdapter`(继承现有 `AbstractMessagePlatformAdapter` 并扩展通用 API 方法),保留旧基类 |
|
||||||
|
| 1.6 | 定义 API 能力声明 | `api/definition/abstract/platform/capabilities.py`(新文件) | `AdapterCapabilities` 数据类,声明适配器支持的事件和 API |
|
||||||
|
| 1.7 | 定义 `NotSupportedError` | `api/entities/builtin/platform/errors.py`(新文件) | 可选 API 未实现时抛出的异常 |
|
||||||
|
|
||||||
|
### 2.2 关键设计约束
|
||||||
|
|
||||||
|
- 所有新增定义以**新增文件或新增类**的方式引入,**不修改**现有类的字段和方法签名
|
||||||
|
- 现有 `AbstractMessagePlatformAdapter` 保留不动,新基类 `AbstractPlatformAdapter` 继承它
|
||||||
|
- 新事件类与旧事件类并存,通过 `event_type` 字段(命名空间字符串)区分
|
||||||
|
|
||||||
|
### 2.3 验收标准
|
||||||
|
|
||||||
|
- [ ] 所有新增类可正常 import 且通过类型检查
|
||||||
|
- [ ] 现有 `FriendMessage`, `GroupMessage`, `AbstractMessagePlatformAdapter` 等类行为不变
|
||||||
|
- [ ] 新增单元测试覆盖事件序列化/反序列化、实体构造
|
||||||
|
- [ ] SDK 版本号 minor bump(如 `0.x.0` → `0.x+1.0`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Phase 2:适配器重构
|
||||||
|
|
||||||
|
**目标**:将现有单文件适配器迁移到独立目录结构,实现新事件监听和通用 API。
|
||||||
|
|
||||||
|
**仓库**:`LangBot`(后端)
|
||||||
|
|
||||||
|
### 3.1 适配器迁移优先级
|
||||||
|
|
||||||
|
根据用户量和代表性,建议按以下顺序迁移:
|
||||||
|
|
||||||
|
| 优先级 | 适配器 | 理由 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| P0 | **Telegram** | 用户量大,API 最完善,适合作为参考实现 |
|
||||||
|
| P0 | **Discord** | 国际用户主要平台,事件类型丰富 |
|
||||||
|
| P1 | **aiocqhttp**(OneBot v11) | 国内 QQ 用户主要适配器 |
|
||||||
|
| P1 | **Satori** | 通用协议适配器,覆盖多个平台 |
|
||||||
|
| P2 | **Lark** / **DingTalk** / **Slack** | 企业平台,用户量中等 |
|
||||||
|
| P2 | **qqofficial** / **WeChat 系列** | 国内用户 |
|
||||||
|
| P3 | **Kook** / **LINE** / **WeCom 系列** | 用户量较小 |
|
||||||
|
| P3 | **WebSocket** | 内置适配器,相对简单 |
|
||||||
|
| P4 | **legacy/*** | 遗留适配器,按需决定是否迁移或废弃 |
|
||||||
|
|
||||||
|
### 3.2 单个适配器迁移步骤(以 Telegram 为例)
|
||||||
|
|
||||||
|
| # | 任务 | 说明 |
|
||||||
|
|---|------|------|
|
||||||
|
| 2.1 | 创建目录结构 | `pkg/platform/adapters/telegram/` 下创建 `__init__.py`, `adapter.py`, `event_converter.py`, `message_converter.py`, `api_impl.py`, `types.py`, `manifest.yaml` |
|
||||||
|
| 2.2 | 迁移消息转换器 | 将 `TelegramMessageConverter` 从 `sources/telegram.py` 搬到 `adapters/telegram/message_converter.py`,逻辑不变 |
|
||||||
|
| 2.3 | 重写事件转换器 | 新的 `TelegramEventConverter` 支持将 Telegram Update 转换为所有通用事件类型(不只是消息),不支持的事件转为 `PlatformSpecificEvent` |
|
||||||
|
| 2.4 | 实现通用 API | 在 `api_impl.py` 中实现 `edit_message`, `delete_message`, `get_group_info` 等 Telegram 支持的通用 API |
|
||||||
|
| 2.5 | 实现透传 API | 在 `adapter.py` 中实现 `call_platform_api`,将 action 映射到 Telegram Bot API 调用 |
|
||||||
|
| 2.6 | 声明能力 | 在 `manifest.yaml` 或适配器类中声明支持的事件和 API 列表 |
|
||||||
|
| 2.7 | 新建 Adapter 主类 | `TelegramAdapter` 继承 `AbstractPlatformAdapter`(新基类),委托各模块实现 |
|
||||||
|
| 2.8 | 更新 manifest.yaml | 更新 `execution.python.path` 指向新位置 |
|
||||||
|
| 2.9 | 验证 | 确保新适配器通过现有消息收发流程的测试 |
|
||||||
|
|
||||||
|
### 3.3 基础设施任务
|
||||||
|
|
||||||
|
| # | 任务 | 说明 |
|
||||||
|
|---|------|------|
|
||||||
|
| 2.A | 创建 `adapters/_base/` | 将 SDK 中新基类的运行时辅助代码放在此处(如事件分发辅助函数) |
|
||||||
|
| 2.B | 更新 ComponentDiscovery | 使 `discover_blueprint` 支持扫描 `adapters/` 子目录中的 YAML |
|
||||||
|
| 2.C | 更新 `templates/components.yaml` | 将 `fromDirs` 从 `pkg/platform/sources/` 改为 `pkg/platform/adapters/`(过渡期两个都扫描) |
|
||||||
|
| 2.D | 保留旧 sources/ | 过渡期不删除旧文件,通过 manifest 的 `deprecated: true` 标记 |
|
||||||
|
|
||||||
|
### 3.4 验收标准
|
||||||
|
|
||||||
|
- [ ] 已迁移的适配器在新目录结构下正常启动和收发消息
|
||||||
|
- [ ] 新事件(如 `message.edited`)在支持的平台上正确触发
|
||||||
|
- [ ] 通用 API(如 `edit_message`)在支持的平台上正确执行
|
||||||
|
- [ ] 未迁移的适配器(仍在 `sources/`)继续正常工作
|
||||||
|
- [ ] ComponentDiscovery 同时扫描新旧目录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Phase 3:核心系统
|
||||||
|
|
||||||
|
**目标**:实现 EventBus、EventRouter 和事件处理器框架,将事件从适配器分发到不同的处理器。
|
||||||
|
|
||||||
|
**仓库**:`LangBot`(后端)
|
||||||
|
|
||||||
|
### 4.1 任务清单
|
||||||
|
|
||||||
|
| # | 任务 | 文件/模块 | 说明 |
|
||||||
|
|---|------|----------|------|
|
||||||
|
| 3.1 | 实现 EventBus | `pkg/platform/event_bus.py`(新文件) | 事件总线:接收适配器事件,进行日志记录,分发给 EventRouter |
|
||||||
|
| 3.2 | 实现 EventRouter | `pkg/platform/event_router.py`(新文件) | 事件路由引擎:读取 Bot 的 `event_handlers` 配置,匹配事件类型,分发到对应 Handler |
|
||||||
|
| 3.3 | 实现 PipelineHandler | `pkg/platform/handlers/pipeline_handler.py` | 将 `message.received` 事件转为现有 Query,进入 Pipeline 流水线 |
|
||||||
|
| 3.4 | 实现 AgentHandler | `pkg/platform/handlers/agent_handler.py` | 直接调用 RequestRunner 处理事件,不经过 Pipeline 多 Stage 流程 |
|
||||||
|
| 3.5 | 实现 WebhookHandler | `pkg/platform/handlers/webhook_handler.py` | 将事件 POST 到外部 URL,解析响应执行动作(重构现有 WebhookPusher) |
|
||||||
|
| 3.6 | 实现 PluginHandler | `pkg/platform/handlers/plugin_handler.py` | 将事件分发给插件 EventListener(复用现有 plugin_connector 机制) |
|
||||||
|
| 3.7 | Bot 实体扩展 | `pkg/entity/persistence/bot.py` | 新增 `event_handlers` JSON 字段 |
|
||||||
|
| 3.8 | 数据库迁移 | `pkg/persistence/migrations/` | 新增迁移脚本:添加 `event_handlers` 列,将现有 `use_pipeline_uuid` 数据迁移为 `event_handlers` 格式 |
|
||||||
|
| 3.9 | 重构 RuntimeBot | `pkg/platform/botmgr.py` | 将 `initialize()` 中硬编码的 `on_friend_message`/`on_group_message` 回调替换为通过 EventBus 分发所有事件 |
|
||||||
|
| 3.10 | 重构 MessageAggregator | `pkg/pipeline/aggregator.py` | 从 RuntimeBot 解耦,作为 PipelineHandler 的内部机制(只对 `message.received` 事件生效) |
|
||||||
|
| 3.11 | Agent Handler 中 RequestRunner 解耦 | `pkg/provider/runner.py` + handlers | RequestRunner 需要能独立于 Pipeline Stage 运行,为 Agent Handler 提供轻量调用路径 |
|
||||||
|
| 3.12 | HTTP API 扩展 | `pkg/api/http/controller/` | 新增/更新 Bot API 端点以支持 `event_handlers` 的 CRUD |
|
||||||
|
|
||||||
|
### 4.2 数据迁移策略
|
||||||
|
|
||||||
|
现有 Bot 表有 `use_pipeline_uuid` 字段,需要自动迁移为 `event_handlers`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 迁移逻辑伪代码
|
||||||
|
for bot in all_bots:
|
||||||
|
if bot.use_pipeline_uuid:
|
||||||
|
bot.event_handlers = [
|
||||||
|
{
|
||||||
|
"event_type": "message.received",
|
||||||
|
"handler_type": "pipeline",
|
||||||
|
"handler_config": {
|
||||||
|
"pipeline_uuid": bot.use_pipeline_uuid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
bot.event_handlers = []
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 RuntimeBot 重构要点
|
||||||
|
|
||||||
|
当前 `RuntimeBot.initialize()` 硬编码注册两个回调:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 现有代码 (botmgr.py)
|
||||||
|
self.adapter.register_listener(FriendMessage, on_friend_message)
|
||||||
|
self.adapter.register_listener(GroupMessage, on_group_message)
|
||||||
|
```
|
||||||
|
|
||||||
|
重构后改为注册通用事件回调:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 新代码
|
||||||
|
async def on_event(event: Event, adapter: AbstractPlatformAdapter):
|
||||||
|
await self.event_bus.emit(
|
||||||
|
bot_uuid=self.bot_entity.uuid,
|
||||||
|
event=event,
|
||||||
|
adapter=adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注册所有事件类型的统一回调
|
||||||
|
self.adapter.register_listener(Event, on_event)
|
||||||
|
```
|
||||||
|
|
||||||
|
EventBus 接收事件后,调用 EventRouter 按配置分发。
|
||||||
|
|
||||||
|
### 4.4 事件处理器执行流程
|
||||||
|
|
||||||
|
```
|
||||||
|
EventBus.emit(bot_uuid, event, adapter)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
EventRouter.route(bot_uuid, event)
|
||||||
|
│ 查询 bot.event_handlers 配置
|
||||||
|
│ 匹配 event_type(精确匹配 > 通配符 *)
|
||||||
|
▼
|
||||||
|
匹配到的 Handler(s)
|
||||||
|
│
|
||||||
|
├── PipelineHandler.handle(event, adapter)
|
||||||
|
│ │ 仅支持 message.received
|
||||||
|
│ │ 构造 Query → MessageAggregator → QueryPool → Pipeline
|
||||||
|
│ └── 沿用现有完整流水线机制
|
||||||
|
│
|
||||||
|
├── AgentHandler.handle(event, adapter)
|
||||||
|
│ │ 根据 handler_config 选择 RequestRunner
|
||||||
|
│ │ 直接调用 runner.run() 处理事件
|
||||||
|
│ └── 将结果通过 adapter API 回复
|
||||||
|
│
|
||||||
|
├── WebhookHandler.handle(event, adapter)
|
||||||
|
│ │ 序列化事件为 JSON
|
||||||
|
│ │ POST 到 handler_config.url
|
||||||
|
│ └── 解析响应,执行动作(回复消息、调用 API 等)
|
||||||
|
│
|
||||||
|
└── PluginHandler.handle(event, adapter)
|
||||||
|
│ 通过 plugin_connector 分发给插件
|
||||||
|
└── 插件 EventListener 处理
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 验收标准
|
||||||
|
|
||||||
|
- [ ] `message.received` 事件通过 PipelineHandler 正确进入现有 Pipeline(与旧行为一致)
|
||||||
|
- [ ] 新增事件(如 `group.member_joined`)能通过 PluginHandler 分发给插件
|
||||||
|
- [ ] AgentHandler 能直接调用 RequestRunner(至少 `local-agent`)处理事件并回复
|
||||||
|
- [ ] WebhookHandler 能将事件 POST 到外部 URL
|
||||||
|
- [ ] 数据库迁移正确执行,`use_pipeline_uuid` 数据迁移到 `event_handlers`
|
||||||
|
- [ ] 现有 Bot 在不修改配置的情况下行为不变(自动迁移保证)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phase 4:插件 SDK 集成
|
||||||
|
|
||||||
|
**目标**:将新事件和 API 通过插件 SDK 暴露给插件开发者,同时实现兼容层。
|
||||||
|
|
||||||
|
**仓库**:`langbot-plugin-sdk` + `LangBot`
|
||||||
|
|
||||||
|
### 5.1 任务清单
|
||||||
|
|
||||||
|
| # | 任务 | 说明 |
|
||||||
|
|---|------|------|
|
||||||
|
| 4.1 | 新增插件事件包装 | 在 `api/entities/events.py` 中为每个通用事件新增插件级事件类(如 `MessageEditedReceived`, `MemberJoinedReceived`) |
|
||||||
|
| 4.2 | 兼容层实现 | `PersonMessageReceived` / `GroupMessageReceived` 由新的 `MessageReceivedEvent` 自动生成,旧事件作为新事件的 alias |
|
||||||
|
| 4.3 | 新 API 暴露 | 在 `LangBotAPIProxy` 中新增方法:`edit_message`, `delete_message`, `get_group_info`, `get_user_info`, `call_platform_api` 等 |
|
||||||
|
| 4.4 | 通信协议扩展 | 在 `entities/io/actions/enums.py` 中新增 action 枚举(如 `EDIT_MESSAGE`, `DELETE_MESSAGE`, `GET_GROUP_INFO`, `CALL_PLATFORM_API`) |
|
||||||
|
| 4.5 | Runtime Handler 扩展 | 在 PluginConnectionHandler / ControlConnectionHandler 中添加新 action 的处理逻辑 |
|
||||||
|
| 4.6 | EventListener 扩展 | 确保 `@handler()` 装饰器支持注册新事件类型 |
|
||||||
|
| 4.7 | QueryBasedAPI 扩展 | 在 `QueryBasedAPIProxy` 中新增事件上下文相关的 API(如 `get_event_source_adapter`) |
|
||||||
|
|
||||||
|
### 5.2 兼容层详细设计
|
||||||
|
|
||||||
|
```
|
||||||
|
新事件系统 旧事件系统(兼容层)
|
||||||
|
───────────── ─────────────────
|
||||||
|
MessageReceivedEvent ┌→ PersonMessageReceived (chat_type == "private")
|
||||||
|
(chat_type: "private"|"group") ┤
|
||||||
|
└→ GroupMessageReceived (chat_type == "group")
|
||||||
|
```
|
||||||
|
|
||||||
|
**实现方式**:在 RuntimeEventDispatcher 中,当分发 `MessageReceivedEvent` 给插件时,同时生成对应的旧事件类实例。插件可以用新事件类或旧事件类注册 handler,都能收到。
|
||||||
|
|
||||||
|
### 5.3 验收标准
|
||||||
|
|
||||||
|
- [ ] 现有插件(使用旧事件和 API)无需修改即可运行
|
||||||
|
- [ ] 新插件可以使用新事件类型(如 `MemberJoinedReceived`)注册 handler
|
||||||
|
- [ ] 新 API(如 `edit_message`)可通过 `self.edit_message()` 或 `event_context.edit_message()` 调用
|
||||||
|
- [ ] 透传 API `call_platform_api` 可正常调用适配器特有功能
|
||||||
|
- [ ] 所有新 action 的通信协议正确工作(stdio / WebSocket)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Phase 5:WebUI 编排面板
|
||||||
|
|
||||||
|
**目标**:在 WebUI 的 Bot 管理页面实现事件处理器的可视化编排。
|
||||||
|
|
||||||
|
**仓库**:`LangBot`(前端 `web/`)
|
||||||
|
|
||||||
|
### 6.1 任务清单
|
||||||
|
|
||||||
|
| # | 任务 | 说明 |
|
||||||
|
|---|------|------|
|
||||||
|
| 5.1 | Bot 编辑页面扩展 | 在 Bot 编辑页面新增「事件处理」面板 |
|
||||||
|
| 5.2 | 事件处理器列表组件 | 可视化展示当前 Bot 的 `event_handlers` 列表,支持增删改排序 |
|
||||||
|
| 5.3 | 事件类型选择器 | 下拉选择事件类型(命名空间分组展示),支持通配符 `*` |
|
||||||
|
| 5.4 | Handler 类型选择与配置 | 选择 handler 类型后展示对应的配置表单(Pipeline 选择器、Runner 选择器、Webhook URL 等) |
|
||||||
|
| 5.5 | Pipeline Handler 配置 | 复用现有的 Pipeline 选择 UI(从现有 `use_pipeline_uuid` 选择器迁移) |
|
||||||
|
| 5.6 | Agent Handler 配置 | Runner 选择器(local-agent / dify / n8n / coze 等)+ Runner 参数配置表单 |
|
||||||
|
| 5.7 | Webhook Handler 配置 | URL 输入、认证方式选择、Header 配置 |
|
||||||
|
| 5.8 | Plugin Handler 配置 | 通常无需额外配置,分发给所有匹配的插件 EventListener |
|
||||||
|
| 5.9 | HTTP API 对接 | 前端调用后端 API 保存/读取 `event_handlers` 配置 |
|
||||||
|
| 5.10 | 迁移提示 | 对于从旧版本升级的用户,如果检测到 `use_pipeline_uuid` 已自动迁移,展示提示说明 |
|
||||||
|
|
||||||
|
### 6.2 UI 交互设计概要
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Bot 编辑页面 ─────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ 基本信息 │ 适配器配置 │ ★ 事件处理 │ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ 事件处理器列表 ────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ① message.received → Pipeline: "主流水线" │ │
|
||||||
|
│ │ [编辑] [删除] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ② group.member_joined → Agent: local-agent │ │
|
||||||
|
│ │ [编辑] [删除] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ③ * (默认) → Plugin │ │
|
||||||
|
│ │ [编辑] [删除] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [+ 添加事件处理器] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └──────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [保存] [取消] │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 验收标准
|
||||||
|
|
||||||
|
- [ ] 用户可以在 WebUI 上为 Bot 添加/编辑/删除事件处理器
|
||||||
|
- [ ] 四种 Handler 类型均有对应的配置表单
|
||||||
|
- [ ] 配置保存后正确写入数据库 `event_handlers` 字段
|
||||||
|
- [ ] 旧版本升级后,自动迁移的配置在 UI 上正确展示
|
||||||
|
- [ ] Pipeline Handler 的行为与旧的 `use_pipeline_uuid` 完全一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Phase 6:文档与示例
|
||||||
|
|
||||||
|
**目标**:更新所有面向开发者的文档和示例。
|
||||||
|
|
||||||
|
**仓库**:`langbot-wiki`, `langbot-plugin-demo`
|
||||||
|
|
||||||
|
### 7.1 任务清单
|
||||||
|
|
||||||
|
| # | 任务 | 仓库 | 说明 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 6.1 | EBA 架构概览文档 | langbot-wiki | 面向用户的新架构说明 |
|
||||||
|
| 6.2 | 适配器开发指南更新 | langbot-wiki | 如何开发一个新的适配器(新目录结构、新基类、事件转换等) |
|
||||||
|
| 6.3 | 插件开发指南更新 | langbot-wiki | 新事件类型、新 API 的使用说明 |
|
||||||
|
| 6.4 | 插件迁移指南 | langbot-wiki | 现有插件如何迁移到新事件/API(如果需要使用新能力) |
|
||||||
|
| 6.5 | 事件处理器配置指南 | langbot-wiki | WebUI 上如何配置事件处理器 |
|
||||||
|
| 6.6 | 示例插件更新 | langbot-plugin-demo | HelloPlugin 增加新事件监听示例、新 API 调用示例 |
|
||||||
|
| 6.7 | 新示例插件 | langbot-plugin-demo | 新建一个示例展示非消息事件处理(如入群欢迎) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 风险评估与缓解
|
||||||
|
|
||||||
|
### 8.1 技术风险
|
||||||
|
|
||||||
|
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| 适配器迁移中断现有功能 | 高 | 中 | 新旧目录并存,ComponentDiscovery 同时扫描两个目录,逐个适配器迁移验证 |
|
||||||
|
| 事件模型不兼容导致插件崩溃 | 高 | 低 | 兼容层保证旧事件类型继续工作,新增类不修改旧类 |
|
||||||
|
| 数据库迁移失败 | 高 | 低 | 迁移脚本做前置校验,`use_pipeline_uuid` 在过渡期保留不删除 |
|
||||||
|
| RequestRunner 解耦破坏 Pipeline | 高 | 中 | Agent Handler 调用 Runner 的路径独立于 Pipeline,不修改现有 Pipeline Stage 中的 Runner 调用逻辑 |
|
||||||
|
| 性能回退(EventBus 额外开销) | 中 | 低 | EventBus 在进程内同步分发,无额外序列化/网络开销 |
|
||||||
|
| 各平台事件差异大难以统一 | 中 | 中 | 通用事件只抽象最大公约数字段,差异部分保留在 `source_platform_object`;不支持的事件走 `PlatformSpecificEvent` |
|
||||||
|
|
||||||
|
### 8.2 兼容性风险
|
||||||
|
|
||||||
|
| 风险 | 缓解措施 |
|
||||||
|
|------|----------|
|
||||||
|
| 现有插件使用旧事件类 | 兼容层自动将新事件转为旧事件分发,两种事件类都能注册 handler |
|
||||||
|
| 现有插件调用 `reply()` / `send_message()` | 这两个 API 保持不变,只是底层实现可能微调 |
|
||||||
|
| 第三方基于 `AbstractMessagePlatformAdapter` 开发的适配器 | 旧基类保留,新基类继承旧基类,第三方适配器无需立即迁移 |
|
||||||
|
| 用户自定义 Pipeline 配置 | Pipeline 机制完整保留,PipelineHandler 只是入口变了(从 RuntimeBot 硬编码变为 EventRouter 配置) |
|
||||||
|
|
||||||
|
### 8.3 回滚策略
|
||||||
|
|
||||||
|
每个 Phase 独立可回滚:
|
||||||
|
|
||||||
|
- **Phase 1**(SDK 新增类):删除新增文件,回退 SDK 版本号
|
||||||
|
- **Phase 2**(适配器目录):恢复 `components.yaml` 的 `fromDirs` 指向旧目录,旧 sources/ 未删除
|
||||||
|
- **Phase 3**(核心系统):回退数据库迁移,恢复 RuntimeBot 旧的硬编码回调
|
||||||
|
- **Phase 4**(插件集成):回退 SDK 版本,插件使用旧版 SDK
|
||||||
|
- **Phase 5**(WebUI):前端回退,Bot 编辑页面隐藏事件处理面板
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 里程碑与时间线建议
|
||||||
|
|
||||||
|
| 里程碑 | 阶段 | 预期产出 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| M1 | Phase 1 完成 | SDK 新版本发布,包含新事件/实体/基类定义 |
|
||||||
|
| M2 | Phase 2 首批适配器(Telegram + Discord) | 两个参考实现,验证目录结构和事件/API 体系 |
|
||||||
|
| M3 | Phase 3 核心系统 | EventBus + EventRouter + 四种 Handler 可用 |
|
||||||
|
| M4 | Phase 2 剩余适配器 | 所有活跃适配器迁移完成 |
|
||||||
|
| M5 | Phase 4 插件集成 | 新 SDK 发布,插件可使用新事件和 API |
|
||||||
|
| M6 | Phase 5 WebUI | 事件处理器编排面板上线 |
|
||||||
|
| M7 | Phase 6 文档 | 开发者文档和示例更新完毕 |
|
||||||
|
|
||||||
|
建议 M1-M3 作为第一个大版本发布(如 v5.0),M4-M7 在后续小版本迭代中完成。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 开发指引
|
||||||
|
|
||||||
|
### 10.1 分支策略
|
||||||
|
|
||||||
|
建议在主仓库创建 `feature/eba` 长期特性分支,各 Phase 在子分支上开发后合入特性分支:
|
||||||
|
|
||||||
|
```
|
||||||
|
main
|
||||||
|
└── feature/eba
|
||||||
|
├── feature/eba-sdk-entities (Phase 1)
|
||||||
|
├── feature/eba-adapter-telegram (Phase 2)
|
||||||
|
├── feature/eba-adapter-discord (Phase 2)
|
||||||
|
├── feature/eba-core-system (Phase 3)
|
||||||
|
├── feature/eba-plugin-sdk (Phase 4)
|
||||||
|
└── feature/eba-webui (Phase 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 测试策略
|
||||||
|
|
||||||
|
| 层次 | 测试内容 | 工具 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 单元测试 | 事件序列化/反序列化、实体构造、API 调用 mock | pytest |
|
||||||
|
| 集成测试 | EventBus → EventRouter → Handler 全链路 | pytest + asyncio |
|
||||||
|
| 适配器测试 | 各适配器的事件转换、消息转换、API 调用 | pytest + mock SDK |
|
||||||
|
| 端到端测试 | 从模拟平台事件到完整处理流程 | staging 环境 |
|
||||||
|
| 插件兼容性测试 | 旧插件在新系统下的行为 | langbot-plugin-demo |
|
||||||
|
|
||||||
|
### 10.3 代码审查关注点
|
||||||
|
|
||||||
|
- 新增代码是否影响现有行为
|
||||||
|
- 兼容层是否正确映射所有旧事件/API 场景
|
||||||
|
- 数据库迁移是否可逆
|
||||||
|
- 新 API 的错误处理(`NotSupportedError`)是否一致
|
||||||
|
- 事件模型的序列化在 stdio/WebSocket 通信中是否正确
|
||||||
39
docs/event-based-agents/adapters/00-index.md
Normal file
39
docs/event-based-agents/adapters/00-index.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# EBA Adapter Migration Records
|
||||||
|
|
||||||
|
This directory records adapter-level migration details for the Event-Based Agents architecture. Each adapter document should be kept close to the implementation and must answer four questions:
|
||||||
|
|
||||||
|
1. What changed in the adapter structure.
|
||||||
|
2. Which configuration fields are required.
|
||||||
|
3. Which events and APIs are supported.
|
||||||
|
4. What has been verified end to end.
|
||||||
|
|
||||||
|
## Adapter Documents
|
||||||
|
|
||||||
|
General acceptance checklist: [EBA Adapter Acceptance Checklist](./acceptance-checklist.md)
|
||||||
|
|
||||||
|
Current acceptance report: [EBA Adapter Acceptance Report](./acceptance-report.md)
|
||||||
|
|
||||||
|
| Adapter | Status | Document |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| Telegram | Migrated; partial plugin E2E, real UI inbound image/file verified | [Telegram](./telegram.md) |
|
||||||
|
| Discord | Migrated; partial plugin E2E, media-inbound gaps remain | [Discord](./discord.md) |
|
||||||
|
| OneBot v11 / aiocqhttp | Migrated; Matcha UI plus protocol-level multi-component coverage | [OneBot v11 / aiocqhttp](./aiocqhttp.md) |
|
||||||
|
| DingTalk | Migrated; partial plugin E2E, real UI inbound image/file verified; group gap remains | [DingTalk](./dingtalk.md) |
|
||||||
|
| Lark / Feishu | Migrated; partial live text E2E, media-inbound gap remains | [Lark / Feishu](./lark.md) |
|
||||||
|
| WeCom | Migrated; private text plugin E2E verified, media/group gaps remain | [WeCom](./wecom.md) |
|
||||||
|
| WeComBot | Migrated; private text and outbound/API plugin E2E verified, feedback/group gaps remain | [WeComBot](./wecombot.md) |
|
||||||
|
| Official Account | Migrated; private text plugin E2E verified, proactive outbound not supported | [Official Account](./officialaccount.md) |
|
||||||
|
| QQ Official API | Migrated; WebSocket inbound reached LangBot, model config blocked reply | [QQ Official API](./qqofficial.md) |
|
||||||
|
| Slack | Migrated; private text and outbound/API plugin E2E verified | [Slack](./slack.md) |
|
||||||
|
|
||||||
|
## Documentation Checklist
|
||||||
|
|
||||||
|
When migrating a new adapter, add one document here with:
|
||||||
|
|
||||||
|
- Configuration table matching the adapter manifest.
|
||||||
|
- Supported event list.
|
||||||
|
- Supported common API list.
|
||||||
|
- Supported `call_platform_api` action list.
|
||||||
|
- Known unsupported APIs and the reason.
|
||||||
|
- Live test notes, including platform, channel type, destructive operations, and residual risks.
|
||||||
|
- A clear distinction between real UI inbound media, protocol-level injected inbound media, and bot outbound media.
|
||||||
208
docs/event-based-agents/adapters/acceptance-checklist.md
Normal file
208
docs/event-based-agents/adapters/acceptance-checklist.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# EBA Adapter Acceptance Checklist
|
||||||
|
|
||||||
|
This checklist is the architecture-level acceptance standard for every Event-Based Agents platform adapter. It is not platform-specific. Adapter migration is not complete until the adapter has a written result against this checklist.
|
||||||
|
|
||||||
|
## Evidence Levels
|
||||||
|
|
||||||
|
Use these evidence levels consistently in adapter records:
|
||||||
|
|
||||||
|
| Level | Meaning | Can Mark Complete |
|
||||||
|
|-------|---------|-------------------|
|
||||||
|
| `plugin-e2e-ui` | Real SDK plugin running through standalone runtime, LangBot core, the migrated adapter, and a real platform/simulator UI action. | Yes |
|
||||||
|
| `plugin-e2e-protocol` | Real SDK plugin running through standalone runtime, LangBot core, and the migrated adapter from a protocol-boundary event injection, such as a OneBot reverse WebSocket event. | Partial; must not be claimed as UI coverage |
|
||||||
|
| `plugin-e2e-outbound` | Real SDK plugin calls an API and the bot output is visible in the real platform/simulator UI. | Yes for send/API coverage only |
|
||||||
|
| `adapter-live` | Direct adapter probe connected to a real or simulator platform endpoint, bypassing plugin runtime. | No, auxiliary only |
|
||||||
|
| `unit` | Unit/API-shape tests with mocked platform SDK objects or mocked APIs. | No, auxiliary only |
|
||||||
|
| `not-supported` | Platform protocol or SDK has no equivalent capability. Must include reason and source. | Yes, as explicitly unsupported |
|
||||||
|
| `blocked` | Intended capability could not be verified because of credentials, permissions, endpoint gaps, or simulator gaps. | No |
|
||||||
|
|
||||||
|
The primary acceptance path must be `plugin-e2e-ui` for inbound UI-triggered behavior and `plugin-e2e-outbound` for bot send/API behavior. `adapter-live`, `plugin-e2e-protocol`, and `unit` tests are useful, but they must be labelled precisely.
|
||||||
|
|
||||||
|
## Required Architecture Path
|
||||||
|
|
||||||
|
Every adapter must prove this full path:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Real platform / simulator UI
|
||||||
|
-> platform SDK native event
|
||||||
|
-> adapter event converter
|
||||||
|
-> unified EBA event/entity/message types
|
||||||
|
-> LangBot core event dispatch
|
||||||
|
-> standalone SDK runtime
|
||||||
|
-> real test plugin listener
|
||||||
|
-> plugin calls platform APIs through SDK
|
||||||
|
-> LangBot core API dispatch
|
||||||
|
-> adapter API implementation
|
||||||
|
-> real platform / simulator UI
|
||||||
|
```
|
||||||
|
|
||||||
|
The test plugin must record JSONL evidence containing:
|
||||||
|
|
||||||
|
- event class and `event.type`
|
||||||
|
- `bot_uuid` and `adapter_name` as received by the plugin
|
||||||
|
- adapter name
|
||||||
|
- chat type and chat ID
|
||||||
|
- sender/user/group IDs with secrets redacted
|
||||||
|
- message component list for received messages
|
||||||
|
- API action name, input summary, result or error
|
||||||
|
- raw unsupported/blocked reason when an item is skipped
|
||||||
|
|
||||||
|
## Required Message Receive Tests
|
||||||
|
|
||||||
|
For every adapter, inbound message conversion must be tested through `plugin-e2e-ui` for each component the platform can receive. If a protocol-level injection is used, label it `plugin-e2e-protocol`; it proves the adapter/core/plugin path, but it does not prove that the user-facing platform UI can send that component. If the platform UI/simulator cannot create a component, record it as `blocked` with the endpoint limitation.
|
||||||
|
|
||||||
|
| Component | Required Receive Assertion |
|
||||||
|
|-----------|----------------------------|
|
||||||
|
| `Source` | Message ID and timestamp are present and stable enough for reply/get/delete APIs. |
|
||||||
|
| `Plain` | Text is preserved exactly, including spaces and multi-line content. |
|
||||||
|
| `At` | Mentioned user ID is converted to common `At.target`. |
|
||||||
|
| `AtAll` | Broadcast mention is converted to common `AtAll`, if platform supports it. |
|
||||||
|
| `Image` | Image ID, URL, path, or base64 is represented without leaking platform-native segment shape. |
|
||||||
|
| `Voice` | Voice/audio component is represented as `Voice` when the platform exposes it. |
|
||||||
|
| `File` | File name, ID/URL, and size are represented as `File` when available. |
|
||||||
|
| `Quote` | Reply/quote source ID and origin content are represented when the platform exposes it. |
|
||||||
|
| `Face` | Native emoji/sticker/dice/rps-like components are represented as `Face` or documented as platform-specific. |
|
||||||
|
| `Forward` | Merged/forwarded messages are represented as `Forward` when the platform exposes structured content. |
|
||||||
|
| `Unknown` | Unsupported native segments become `Unknown` or `PlatformSpecificEvent` data, not crashes. |
|
||||||
|
| Mixed chain | A message containing multiple component types preserves order. |
|
||||||
|
|
||||||
|
The plugin must subscribe to `MessageReceivedEvent` and assert that `message_chain` contains common `langbot_plugin.api.entities.builtin.platform.message` components, not platform-native SDK objects.
|
||||||
|
|
||||||
|
## Required Message Send Tests
|
||||||
|
|
||||||
|
For every adapter, outbound message conversion must be tested through `plugin-e2e-outbound` by having the plugin call SDK platform APIs and verifying the platform UI/simulator receives the expected message.
|
||||||
|
|
||||||
|
| Component | Required Send Assertion |
|
||||||
|
|-----------|-------------------------|
|
||||||
|
| `Plain` | Text appears exactly on the platform. |
|
||||||
|
| `At` | User mention renders as a mention or platform equivalent. |
|
||||||
|
| `AtAll` | Broadcast mention renders or is explicitly unsupported. |
|
||||||
|
| `Image` | URL, path, or base64 image sends and renders/downloads correctly. |
|
||||||
|
| `Voice` | Voice/audio sends when supported. |
|
||||||
|
| `File` | File sends with name and content/link when supported. |
|
||||||
|
| `Quote` | Quoted reply points to the original message when supported. |
|
||||||
|
| `Face` | Native emoji/sticker/dice/rps sends or is explicitly unsupported. |
|
||||||
|
| `Forward` | Forward/merged-forward sends when supported; otherwise fallback behavior is documented. |
|
||||||
|
| Mixed chain | A mixed chain preserves component order as closely as the platform allows. |
|
||||||
|
|
||||||
|
If a platform supports a component only in one direction, the adapter record must say so explicitly.
|
||||||
|
|
||||||
|
## Required Event Tests
|
||||||
|
|
||||||
|
The plugin must subscribe to every event declared in `manifest.yaml -> spec.supported_events` and record one of `plugin-e2e-ui`, `plugin-e2e-protocol`, `not-supported`, or `blocked`.
|
||||||
|
|
||||||
|
| Event | Required Assertion |
|
||||||
|
|-------|--------------------|
|
||||||
|
| `message.received` | Real message reaches plugin as `MessageReceivedEvent`. |
|
||||||
|
| `message.edited` | Edited message reaches plugin with message ID and new content, if declared. |
|
||||||
|
| `message.deleted` | Deleted/recalled message reaches plugin with message ID and operator when available, if declared. |
|
||||||
|
| `message.reaction` | Reaction add/remove reaches plugin with message ID, user, reaction, and direction, if declared. |
|
||||||
|
| `feedback.received` | Feedback payload reaches plugin with feedback type and message/session IDs, if declared. |
|
||||||
|
| `group.member_joined` | Join event reaches plugin with group and member. |
|
||||||
|
| `group.member_left` | Leave/kick event reaches plugin with group, member, and kick flag. |
|
||||||
|
| `group.member_banned` | Mute/ban event reaches plugin with group, member, operator, and duration. |
|
||||||
|
| `group.info_updated` | Group metadata update reaches plugin with changed fields, if declared. |
|
||||||
|
| `friend.request_received` | Friend request reaches plugin with request ID and message. |
|
||||||
|
| `friend.added` | Friend-added event reaches plugin. |
|
||||||
|
| `friend.removed` | Friend-removed event reaches plugin, if declared. |
|
||||||
|
| `bot.invited_to_group` | Bot invite/join request reaches plugin with group and inviter/request ID. |
|
||||||
|
| `bot.removed_from_group` | Bot removal reaches plugin with group and operator when available. |
|
||||||
|
| `bot.muted` | Bot mute reaches plugin with duration. |
|
||||||
|
| `bot.unmuted` | Bot unmute reaches plugin. |
|
||||||
|
| `platform.specific` | At least one unmapped native event is delivered as structured platform-specific data, if declared. |
|
||||||
|
|
||||||
|
Do not declare an event in the manifest unless there is an implementation path and an acceptance entry.
|
||||||
|
|
||||||
|
## Required Common API Tests
|
||||||
|
|
||||||
|
The plugin must call every common API declared in `manifest.yaml -> spec.supported_apis.required` and `optional`. Each call must be recorded with input summary and result.
|
||||||
|
|
||||||
|
| API | Required Assertion |
|
||||||
|
|-----|--------------------|
|
||||||
|
| `send_message` | Plugin sends to private and group/channel targets where supported. |
|
||||||
|
| `reply_message` | Plugin replies to the triggering message, with quoted mode tested when supported. |
|
||||||
|
| `edit_message` | Plugin edits a bot-sent message, if declared. |
|
||||||
|
| `delete_message` | Plugin deletes/recalls a bot-sent message, if declared and permissions allow. |
|
||||||
|
| `forward_message` | Plugin forwards or emulates forwarding a real message, if declared. |
|
||||||
|
| `get_message` | Plugin retrieves a real message and receives common `MessageReceivedEvent` shape. |
|
||||||
|
| `get_group_info` | Plugin receives `UserGroup` with ID/name/count where available. |
|
||||||
|
| `get_group_list` | Plugin receives joined groups/channels list where supported. |
|
||||||
|
| `get_group_member_list` | Plugin receives list of `UserGroupMember` where supported. |
|
||||||
|
| `get_group_member_info` | Plugin receives one member with role/display name where available. |
|
||||||
|
| `set_group_name` | Plugin changes and restores a disposable group name, if declared. |
|
||||||
|
| `mute_member` | Plugin mutes a disposable target, if declared. |
|
||||||
|
| `unmute_member` | Plugin unmutes the same target, if declared. |
|
||||||
|
| `kick_member` | Plugin kicks a disposable target only in destructive test mode, if declared. |
|
||||||
|
| `leave_group` | Plugin leaves only in destructive test mode and only at the end, if declared. |
|
||||||
|
| `get_user_info` | Plugin receives common `User` shape. |
|
||||||
|
| `get_friend_list` | Plugin receives friend/contact list where supported. |
|
||||||
|
| `approve_friend_request` | Plugin accepts/rejects a disposable friend request, if declared. |
|
||||||
|
| `approve_group_invite` | Plugin accepts/rejects a disposable group invite, if declared. |
|
||||||
|
| `upload_file` | Plugin uploads a real small file, if declared. |
|
||||||
|
| `get_file_url` | Plugin resolves a real file ID to a URL, if declared. |
|
||||||
|
| `call_platform_api` | Plugin calls every declared platform-specific action with safe parameters. |
|
||||||
|
|
||||||
|
Destructive APIs must be opt-in and documented with the exact target used.
|
||||||
|
|
||||||
|
The SDK must expose a plugin-side platform API escape hatch for adapter-specific actions. The acceptance plugin should call it from the same EBA event handler that received the real platform event, so the evidence proves both directions of the path:
|
||||||
|
|
||||||
|
```text
|
||||||
|
plugin -> SDK call_platform_api -> LangBot core -> adapter call_platform_api -> platform SDK/API
|
||||||
|
```
|
||||||
|
|
||||||
|
The result must be serialized into JSON-safe values before it is returned to the plugin runtime.
|
||||||
|
|
||||||
|
## Platform-Specific API Tests
|
||||||
|
|
||||||
|
Every action listed in `manifest.yaml -> spec.platform_specific_apis` must have one acceptance entry:
|
||||||
|
|
||||||
|
- `plugin-e2e-ui` or `plugin-e2e-outbound`: called by the plugin against the live/simulator endpoint.
|
||||||
|
- `plugin-e2e-protocol`: called by the plugin after a protocol-boundary injected event; useful for endpoint-specific simulators but must be labelled.
|
||||||
|
- `not-supported`: removed from manifest or explained if the platform SDK exposes it but this adapter intentionally does not.
|
||||||
|
- `blocked`: endpoint did not implement it, permissions missing, or safe fixture unavailable.
|
||||||
|
|
||||||
|
Do not leave a platform-specific API in the manifest without a corresponding test record.
|
||||||
|
|
||||||
|
## Required Compatibility Tests
|
||||||
|
|
||||||
|
Each migrated adapter must also prove:
|
||||||
|
|
||||||
|
- Manifest supported events match `adapter.get_supported_events()`.
|
||||||
|
- Manifest supported APIs match `adapter.get_supported_apis()`.
|
||||||
|
- Manifest platform-specific actions match `PLATFORM_API_MAP`.
|
||||||
|
- Legacy `FriendMessage` / `GroupMessage` listeners still work when the core registers them.
|
||||||
|
- EBA listener dispatch prefers the most specific event class, then `EBAEvent`, then base `Event`.
|
||||||
|
- Self-message filtering prevents bot echo loops without dropping edit/delete/moderation events needed for API tests.
|
||||||
|
- `source_platform_object` is present for reply/debug but not required by plugins for common behavior.
|
||||||
|
|
||||||
|
## Required Documentation Per Adapter
|
||||||
|
|
||||||
|
Each adapter document must include:
|
||||||
|
|
||||||
|
- adapter directory and manifest name
|
||||||
|
- config table
|
||||||
|
- supported event table with evidence level per event
|
||||||
|
- supported common API table with evidence level per API
|
||||||
|
- platform-specific API table with evidence level per action
|
||||||
|
- receive component table with evidence level per component
|
||||||
|
- send component table with evidence level per component
|
||||||
|
- exact test date
|
||||||
|
- exact platform endpoint or simulator used
|
||||||
|
- standalone runtime command
|
||||||
|
- plugin path/name used for testing
|
||||||
|
- evidence JSONL path
|
||||||
|
- destructive operations performed or explicitly skipped
|
||||||
|
- blocked items and reasons
|
||||||
|
|
||||||
|
## Acceptance Rule
|
||||||
|
|
||||||
|
An adapter can be marked migrated only when:
|
||||||
|
|
||||||
|
1. All declared events have `plugin-e2e-ui`, justified `plugin-e2e-protocol`, or `not-supported` evidence.
|
||||||
|
2. All declared APIs have `plugin-e2e-outbound` or `not-supported` evidence.
|
||||||
|
3. All platform-supported receive components have `plugin-e2e-ui` evidence; protocol-only receive coverage keeps the status partial.
|
||||||
|
4. All platform-supported send components have `plugin-e2e-outbound` evidence.
|
||||||
|
5. Unit tests cover conversion and API-shape boundaries.
|
||||||
|
6. The adapter document lists every blocked or skipped item honestly.
|
||||||
|
|
||||||
|
If any declared capability is only covered by `adapter-live` or `unit`, the adapter status must remain partial.
|
||||||
171
docs/event-based-agents/adapters/acceptance-report.md
Normal file
171
docs/event-based-agents/adapters/acceptance-report.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# EBA Adapter Acceptance Report
|
||||||
|
|
||||||
|
Date: May 10, 2026
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
- `telegram-eba`
|
||||||
|
- `discord-eba`
|
||||||
|
- `aiocqhttp-eba`
|
||||||
|
- `dingtalk-eba`
|
||||||
|
- `lark-eba`
|
||||||
|
- `wecom-eba`
|
||||||
|
- `wecombot-eba`
|
||||||
|
- `wecomcs-eba`
|
||||||
|
- `officialaccount-eba`
|
||||||
|
- `qqofficial-eba`
|
||||||
|
- `slack-eba`
|
||||||
|
|
||||||
|
This report follows `acceptance-checklist.md`. Evidence levels are intentionally strict:
|
||||||
|
|
||||||
|
- `plugin-e2e-ui`: real platform or simulator UI event reached LangBot, standalone runtime, and `EBAEventProbe`.
|
||||||
|
- `plugin-e2e-protocol`: real adapter endpoint event reached LangBot, standalone runtime, and `EBAEventProbe`, but the event was injected at the platform protocol boundary rather than sent through the UI.
|
||||||
|
- `plugin-e2e-outbound`: the plugin called SDK APIs and the resulting bot message was visible on the platform.
|
||||||
|
- `unit`: mocked converter/API coverage only.
|
||||||
|
- `blocked`: not completed, either because the platform/simulator/client could not trigger it or because a safe disposable fixture was unavailable.
|
||||||
|
- `not-supported`: the platform has no equivalent capability.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Adapter | Status | Honest acceptance summary |
|
||||||
|
|---------|--------|---------------------------|
|
||||||
|
| Telegram | Partial EBA acceptance | Real Telegram UI covered private text, group mention text, bot invite, inbound private image/file, outbound component sweep, safe SDK APIs, and safe Telegram platform APIs. Real UI inbound voice/quote was not completed in the latest plugin run. |
|
||||||
|
| Discord | Partial EBA acceptance | Real Discord UI covered group text, outbound image/file/quote/mention components, safe SDK APIs, and safe Discord platform APIs. Real UI inbound attachment/image/file/reply/mention was not completed. A later UI retry was blocked because the Discord client kept the send button disabled. |
|
||||||
|
| OneBot v11 / aiocqhttp | Partial EBA acceptance | Matcha UI covered real group text and outbound supported components/APIs. Multi-component inbound `Source/Plain/At/Face/Image/Voice/File/Quote` was verified through the real OneBot reverse WebSocket adapter endpoint, but not through Matcha UI upload/send. Matcha blocks file-send and merged-forward APIs. |
|
||||||
|
| DingTalk | Partial EBA acceptance | Real DingTalk UI covered private text, emoji-as-text inbound, private inbound image/file, outbound image/file/quote/mention fallback components, safe SDK APIs, and safe DingTalk platform APIs. Real UI inbound voice/quote and group trigger were not completed. |
|
||||||
|
| Lark / Feishu | Partial EBA acceptance | EBA adapter structure, self-built/store app config, WebSocket/Webhook mode handling, converters, common APIs, platform APIs, and unit tests are in place. One real LangBot organization WebSocket private text event reached `EBAEventProbe`; outbound component sweep was visible in Feishu. Latest real UI image/file sends did not reach local plugin evidence, so media receive remains blocked. |
|
||||||
|
| WeCom | Partial EBA acceptance | Regular WeCom application-message adapter is split into the EBA directory with manifest, converters, API mixin, platform API map, and unit tests. Private text reached `EBAEventProbe` through standalone runtime and the real WeCom client; safe plugin APIs passed. Real inbound media and broader event coverage remain pending. |
|
||||||
|
| WeComBot | Partial EBA acceptance | WeCom AI Bot is split into the EBA directory with WebSocket long connection mode and optional webhook mode, EBA message/feedback/platform-specific conversion, cache-backed common APIs, platform API map, unit tests, and a direct live probe. Private text, outbound component sweep, safe common APIs, and all declared WeComBot platform APIs reached `EBAEventProbe`; group, real inbound media, and feedback callback evidence remain pending. |
|
||||||
|
| WeCom Customer Service | Partial EBA acceptance | WeCom Customer Service is split into the EBA directory with manifest, converters, API mixin, platform API map, unit tests, docs, and a direct live probe scaffold. Real WeChat customer-side UI text reached `EBAEventProbe`; plugin outbound text/image and safe cache-backed common APIs passed. Inbound media and platform-specific API live coverage remain pending; later fallback text sends were blocked by WeCom `95001 send msg count limit`. |
|
||||||
|
| Official Account | Partial EBA acceptance | WeChat Official Account is split into the EBA directory with manifest, converters, cache-backed safe APIs, platform API map, unit tests, and a direct live probe scaffold. Real WeChat Official Account UI private text reached `EBAEventProbe`; safe cache-backed common APIs and declared platform APIs passed. Proactive outbound `send_message` is not supported because replies must be tied to inbound webhook windows; inbound image/voice live UI evidence remains pending. |
|
||||||
|
| QQ Official API | Partial EBA acceptance | QQ Official API is split into the EBA directory with manifest, converters, cache-backed safe APIs, platform API map, unit tests, docs, and a direct live probe scaffold. A real WebSocket-mode QQ Official bot reached the LangBot pipeline on `dev.rockchin.top`; reply/outbound evidence is blocked by the test model provider returning `model_not_found` for `deepseek-v3`. |
|
||||||
|
| Slack | Partial EBA acceptance | Slack is split into the EBA directory with manifest, converters, cache-backed safe APIs, platform API map, unit tests, docs, and a direct live probe scaffold. Real Slack private text reached `EBAEventProbe`; safe common APIs, outbound component fallback sweep, and declared Slack platform APIs passed. Channel mention and real inbound media evidence remain pending. |
|
||||||
|
|
||||||
|
Telegram and DingTalk now have real user-side UI image/file upload evidence in plugin JSONL. Discord and aiocqhttp do not yet have real UI inbound image/file evidence.
|
||||||
|
|
||||||
|
## Evidence Files
|
||||||
|
|
||||||
|
| Adapter | Endpoint | Evidence |
|
||||||
|
|---------|----------|----------|
|
||||||
|
| Telegram private | Telegram Lite, `@rockchinq_bot` private chat | `data/temp/telegram-plugin-e2e-rerun.jsonl` |
|
||||||
|
| Telegram private media | Telegram Lite, `@rockchinq_bot` private chat | `data/temp/telegram-plugin-e2e-media-ui.jsonl` |
|
||||||
|
| Telegram group | Telegram Lite, `Rock'sBotGroup` | `data/temp/telegram-plugin-e2e-group.jsonl` |
|
||||||
|
| Discord | Discord client, LangBot server, `#debugging` | `data/temp/discord-plugin-e2e-20260510-final.jsonl` |
|
||||||
|
| aiocqhttp UI | local Matcha, group `test group` | `data/temp/aiocqhttp-plugin-e2e-20260510-multiformat.jsonl` |
|
||||||
|
| aiocqhttp protocol | OneBot reverse WebSocket endpoint `127.0.0.1:2280/ws` | `data/temp/aiocqhttp-plugin-e2e-20260510-multiformat.jsonl` |
|
||||||
|
| DingTalk | DingTalk Mac, `LangBot Team` org private chat | `data/temp/dingtalk-plugin-e2e-20260510-rerun.jsonl` |
|
||||||
|
| DingTalk private media | DingTalk Mac, `LangBot Team` org private chat | `data/temp/dingtalk-plugin-e2e-media-ui.jsonl` |
|
||||||
|
| Lark / Feishu unit | local mocked Feishu SDK/client paths | `tests/unit_tests/platform/test_lark_eba_adapter.py` |
|
||||||
|
| Lark / Feishu partial live | Feishu Mac, LangBot organization `LangBotDev` private chat | `data/temp/lark-plugin-e2e-ws.jsonl` |
|
||||||
|
| WeCom Customer Service | WeChat customer-side UI, `客服消息 -> 浪波智能客服` on `dev.rockchin.top` | `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/wecomcs_eba_plugin_probe.jsonl` |
|
||||||
|
| Official Account | WeChat desktop client, subscribed Official Account on `dev.rockchin.top` | `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/officialaccount_eba_plugin_probe.jsonl` |
|
||||||
|
| QQ Official API unit | local mocked QQ Official client paths | `tests/unit_tests/platform/test_qqofficial_eba_adapter.py` |
|
||||||
|
| Slack unit | local mocked Slack client paths | `tests/unit_tests/platform/test_slack_eba_adapter.py` |
|
||||||
|
| Slack private | Slack workspace private DM on `dev.rockchin.top` | `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/slack_eba_plugin_probe.jsonl` |
|
||||||
|
|
||||||
|
All plugin runs used SDK standalone runtime ports `5400/5401`, LangBot `--standalone-runtime`, and the real plugin at `langbot-plugin-demo/EBAEventProbe`.
|
||||||
|
|
||||||
|
## Unified Shape Verification
|
||||||
|
|
||||||
|
All four adapters deliver common SDK entities to plugins before LangBot core/plugin logic handles the event.
|
||||||
|
|
||||||
|
| Requirement | Telegram | Discord | aiocqhttp | DingTalk | Lark / Feishu |
|
||||||
|
|-------------|----------|---------|-----------|----------|---------------|
|
||||||
|
| `bot_uuid` filled | plugin-e2e | plugin-e2e | plugin-e2e | plugin-e2e | live plugin-e2e pending |
|
||||||
|
| `adapter_name` filled | `telegram` | `discord` | `aiocqhttp` | `dingtalk` | `lark-eba` in current unit/code; older live text evidence recorded `lark` before the naming fix |
|
||||||
|
| common `MessageChain` delivered | `Plain`, group `At + Plain`, private `Image`, private `File` | `Source + Plain` | UI `Source + Plain`; protocol `Source + Plain + At + Face + Image + Voice + File + Quote + Plain` | `Source + Plain`, private `Source + Image`, private `Source + File` | live private `Source + Plain`; unit `Source + Plain + At/Image/File`; latest live image/file blocked |
|
||||||
|
| common user/group entities | plugin-e2e | plugin-e2e | plugin-e2e | plugin-e2e private user; group not completed | live private user; unit private/group |
|
||||||
|
| raw native object isolation | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` |
|
||||||
|
|
||||||
|
## Message Receive Components
|
||||||
|
|
||||||
|
| Component | Telegram | Discord | aiocqhttp | DingTalk | Lark / Feishu |
|
||||||
|
|-----------|----------|---------|-----------|----------|---------------|
|
||||||
|
| `Source` | design gap: event has message id but chain omits `Source` | plugin-e2e-ui | plugin-e2e-ui/protocol | plugin-e2e-ui | plugin-e2e-ui private text |
|
||||||
|
| `Plain` | plugin-e2e-ui private/group | plugin-e2e-ui | plugin-e2e-ui/protocol | plugin-e2e-ui | plugin-e2e-ui private text |
|
||||||
|
| `At` | plugin-e2e-ui group mention | unit; real UI mention not completed in latest run | plugin-e2e-protocol; unit | unit; group trigger not completed | unit; group trigger not completed |
|
||||||
|
| `AtAll` | not-supported | unit only | unit only | unit/send fallback only | unit only |
|
||||||
|
| `Image` | plugin-e2e-ui private | converter/unit; real UI attachment not completed | plugin-e2e-protocol, not Matcha UI | plugin-e2e-ui private | unit; real UI image sent but not observed in plugin evidence |
|
||||||
|
| `Voice` | converter/unit; real UI inbound not completed | not-supported as native voice; audio is attachment/file | plugin-e2e-protocol, not Matcha UI | converter/unit; real UI inbound not completed | unit; real UI inbound not completed |
|
||||||
|
| `File` | plugin-e2e-ui private | converter/unit; real UI attachment not completed | plugin-e2e-protocol, not Matcha UI | plugin-e2e-ui private | unit; real UI file sent but not observed in plugin evidence |
|
||||||
|
| `Quote` | converter/unit; real UI reply not completed | unit; real UI reply not completed | plugin-e2e-protocol | converter/unit; real UI quote not completed | unit/API-backed quote lookup; real UI quote not completed |
|
||||||
|
| `Face` | not-supported as common `Face` | not-supported as common `Face` | plugin-e2e-protocol | UI emoji becomes `Plain` (`[smile]` text), not `Face` | not-supported as common `Face` |
|
||||||
|
| `Forward` | not-supported inbound | not-supported inbound | unit; Matcha forward UI/action blocked | not-supported inbound | not-supported inbound |
|
||||||
|
| Mixed chain | group `At + Plain`; media tested as separate messages | not completed inbound | plugin-e2e-protocol | media tested as separate messages; mixed inbound not completed | unit only |
|
||||||
|
|
||||||
|
## Message Send Components
|
||||||
|
|
||||||
|
| Component | Telegram | Discord | aiocqhttp | DingTalk | Lark / Feishu |
|
||||||
|
|-----------|----------|---------|-----------|----------|---------------|
|
||||||
|
| `Plain` | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound |
|
||||||
|
| `At` | plugin-e2e-outbound equivalent | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound fallback/equivalent | plugin-e2e-outbound |
|
||||||
|
| `AtAll` | plugin-e2e-outbound fallback | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound fallback | unit; group live not completed |
|
||||||
|
| `Image` | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound |
|
||||||
|
| `Voice` | not-supported in current send converter | not-supported as native voice | converter path; not completed against Matcha UI | fallback as file/text depending DingTalk media support | converter path; live not completed |
|
||||||
|
| `File` | plugin-e2e-outbound | plugin-e2e-outbound | blocked by Matcha endpoint error | plugin-e2e-outbound | plugin-e2e-outbound |
|
||||||
|
| `Quote` | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound fallback | plugin-e2e-outbound fallback |
|
||||||
|
| `Face` | not-supported | not-supported | plugin-e2e-outbound attempted in mixed chain | fallback text | not-supported |
|
||||||
|
| `Forward` | flattened fallback | flattened fallback | blocked by Matcha unsupported action | flattened fallback | plugin-e2e-outbound flattened fallback |
|
||||||
|
| Mixed chain | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound except blocked file/forward | plugin-e2e-outbound | plugin-e2e-outbound |
|
||||||
|
|
||||||
|
## Event Acceptance
|
||||||
|
|
||||||
|
| Event category | Telegram | Discord | aiocqhttp | DingTalk |
|
||||||
|
|----------------|----------|---------|-----------|----------|
|
||||||
|
| `message.received` | plugin-e2e-ui | plugin-e2e-ui | plugin-e2e-ui and plugin-e2e-protocol | plugin-e2e-ui private |
|
||||||
|
| `message.edited` | implemented/unit, not plugin-e2e-ui | historical/direct only, not latest plugin-e2e | unit | not declared |
|
||||||
|
| `message.deleted` | implemented/unit, not plugin-e2e-ui | historical/direct only, not latest plugin-e2e | unit | not declared |
|
||||||
|
| `message.reaction` | implemented/unit, not plugin-e2e-ui | historical/direct only, not latest plugin-e2e | not-supported in standard OneBot message path | not declared |
|
||||||
|
| member join/left/ban | implemented/unit or blocked without disposable users | blocked without disposable users | unit; Matcha fixture unavailable | not declared |
|
||||||
|
| bot invited/removed | invite plugin-e2e-ui for Telegram; removal blocked | invite historical/plugin-series; removal blocked | unit; Matcha fixture unavailable | not declared |
|
||||||
|
| requests/friend events | not applicable | not applicable | unit; Matcha fixture unavailable | not declared |
|
||||||
|
| `platform.specific` | implemented; not latest plugin-e2e | not latest plugin-e2e | adapter lifecycle observed; plugin focus was message path | declared for fallback; not reproduced in UI run |
|
||||||
|
|
||||||
|
## Common API Acceptance
|
||||||
|
|
||||||
|
| API area | Telegram | Discord | aiocqhttp | DingTalk |
|
||||||
|
|----------|----------|---------|-----------|----------|
|
||||||
|
| send/reply | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound, with Matcha file/forward gaps | plugin-e2e-outbound |
|
||||||
|
| edit/delete | historical/direct or unit; destructive/current UI not repeated | historical/direct; destructive/current UI not repeated | unit/destructive blocked | not declared or blocked |
|
||||||
|
| message lookup | not-supported | not-supported | plugin-e2e | inbound cache-backed where available; limited live coverage |
|
||||||
|
| group info/member info | plugin-e2e safe subset | plugin-e2e safe subset | plugin-e2e safe subset | private path only; group not completed |
|
||||||
|
| user/friend info | plugin-e2e where platform allows | plugin-e2e where platform allows | plugin-e2e | plugin-e2e private user |
|
||||||
|
| moderation/leave | blocked without disposable safe targets | blocked without disposable safe targets | blocked without disposable safe targets | blocked/not declared |
|
||||||
|
| `get_file_url` | implemented; latest inbound `File` carried downloadable file data in plugin evidence | URL passthrough for attachments; inbound attachment not completed | not portable/endpoint-dependent | implemented through DingTalk media API; latest inbound `File` carried a platform file URL |
|
||||||
|
| `call_platform_api` | plugin-e2e safe actions | plugin-e2e safe actions | plugin-e2e safe actions, Matcha gaps documented | plugin-e2e safe `check_access_token` |
|
||||||
|
|
||||||
|
## Platform-Specific API Acceptance
|
||||||
|
|
||||||
|
| Adapter | plugin-e2e verified | Blocked or not reproduced |
|
||||||
|
|---------|---------------------|---------------------------|
|
||||||
|
| Telegram | safe chat/admin/member count/chat-action actions | mutating actions and callback-only actions were not repeated |
|
||||||
|
| Discord | safe channel/guild/role/typing actions | mutating pin/reaction/invite actions were not repeated in the latest plugin run; inbound attachment paths not completed |
|
||||||
|
| aiocqhttp | safe OneBot actions such as status/version/can-send checks | `get_group_honor_info` unsupported by Matcha; admin/card/title/ban/record/file/forward require better endpoint fixtures |
|
||||||
|
| DingTalk | `check_access_token`; real inbound file produced a file URL in the common `File` component | separate media-download replay APIs and group actions need a working follow-up fixture |
|
||||||
|
|
||||||
|
## SDK API Acceptance
|
||||||
|
|
||||||
|
`EBAEventProbe` exercised the standalone runtime path for:
|
||||||
|
|
||||||
|
- bot discovery and bot info lookup
|
||||||
|
- send message
|
||||||
|
- component sweep where enabled
|
||||||
|
- platform API sweep where enabled
|
||||||
|
- plugin storage
|
||||||
|
- workspace storage
|
||||||
|
- plugin/command/tool/knowledge-base list APIs
|
||||||
|
|
||||||
|
The probe logs set `ok=true` when the sweep completed with only expected unsupported/blocked items. Individual call details are stored in the JSONL evidence files.
|
||||||
|
|
||||||
|
## Residual Risks And Required Follow-Up
|
||||||
|
|
||||||
|
- Discord still requires real UI inbound image/file upload evidence before it can be called media-complete.
|
||||||
|
- aiocqhttp has rich inbound component evidence only at the OneBot reverse WebSocket boundary; Matcha UI did not provide image/file upload coverage.
|
||||||
|
- DingTalk group trigger remains unclosed; current evidence is private chat only.
|
||||||
|
- Lark / Feishu requires a clean follow-up live pass: the latest LangBot organization WebSocket run connected, but UI-sent text/image/file after the loop-scheduling fix did not append plugin events.
|
||||||
|
- Discord UI retry on May 10, 2026 was blocked by the client keeping the send button disabled even after text was entered.
|
||||||
|
- Destructive moderation and leave APIs are intentionally blocked until disposable users/groups are available.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The EBA conversion path is implemented and partially proven for the migrated adapters. Telegram and DingTalk now have real UI private-chat image/file inbound evidence. Discord, aiocqhttp, and Lark / Feishu still have explicit UI-level media gaps, so the overall adapter set remains partial acceptance rather than production-complete media acceptance.
|
||||||
162
docs/event-based-agents/adapters/aiocqhttp.md
Normal file
162
docs/event-based-agents/adapters/aiocqhttp.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# OneBot v11 / aiocqhttp EBA Adapter
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
OneBot v11 has been migrated to the EBA adapter directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/langbot/pkg/platform/adapters/aiocqhttp/
|
||||||
|
├── adapter.py
|
||||||
|
├── api_impl.py
|
||||||
|
├── event_converter.py
|
||||||
|
├── manifest.yaml
|
||||||
|
├── message_converter.py
|
||||||
|
├── platform_api.py
|
||||||
|
├── types.py
|
||||||
|
└── onebot.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
The EBA adapter is registered as `aiocqhttp-eba`. The legacy adapter remains at `src/langbot/pkg/platform/sources/aiocqhttp.py`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Default | Description |
|
||||||
|
|-------|----------|---------|-------------|
|
||||||
|
| `host` | Yes | `0.0.0.0` | Host for the reverse WebSocket server that the OneBot endpoint connects to. |
|
||||||
|
| `port` | Yes | `2280` | Reverse WebSocket listen port. |
|
||||||
|
| `access-token` | No | `""` | OneBot access token, if the endpoint is configured to use one. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
The adapter declares these EBA events:
|
||||||
|
|
||||||
|
- `message.received`
|
||||||
|
- `message.deleted`
|
||||||
|
- `group.member_joined`
|
||||||
|
- `group.member_left`
|
||||||
|
- `group.member_banned`
|
||||||
|
- `friend.request_received`
|
||||||
|
- `friend.added`
|
||||||
|
- `bot.invited_to_group`
|
||||||
|
- `bot.removed_from_group`
|
||||||
|
- `bot.muted`
|
||||||
|
- `bot.unmuted`
|
||||||
|
- `platform.specific`
|
||||||
|
|
||||||
|
`platform.specific` is used for OneBot notice/request/meta events that do not yet have a common EBA event type, such as group admin changes, group file uploads, pokes, honor changes, and group join requests from non-bot users.
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Status | Notes |
|
||||||
|
|-----|--------|-------|
|
||||||
|
| `send_message` | Supported | Supports private and group text, mentions, images, voice, files, faces, and flattened forwards. Group merged forwards are sent through OneBot forward APIs when possible. |
|
||||||
|
| `reply_message` | Supported | Uses the original OneBot event and can prepend a reply segment. |
|
||||||
|
| `edit_message` | Not supported | OneBot v11 has no standard message edit action. |
|
||||||
|
| `delete_message` | Supported | Uses `delete_msg`; permission depends on endpoint and group role. |
|
||||||
|
| `forward_message` | Supported | Emulates forward by fetching the source message with `get_msg` and sending its content to the target chat. |
|
||||||
|
| `get_message` | Supported | Uses `get_msg` and converts the response into `MessageReceivedEvent`. |
|
||||||
|
| `get_group_info` | Supported | Uses `get_group_info`. |
|
||||||
|
| `get_group_list` | Supported | Uses `get_group_list`. |
|
||||||
|
| `get_group_member_list` | Supported | Uses `get_group_member_list`. |
|
||||||
|
| `get_group_member_info` | Supported | Uses `get_group_member_info`. |
|
||||||
|
| `set_group_name` | Supported | Uses `set_group_name`; may be unsupported by mock endpoints. |
|
||||||
|
| `get_user_info` | Supported | Uses `get_stranger_info`. |
|
||||||
|
| `get_friend_list` | Supported | Uses `get_friend_list`. |
|
||||||
|
| `approve_friend_request` | Supported | Uses `set_friend_add_request`. |
|
||||||
|
| `approve_group_invite` | Supported | Uses `set_group_add_request` with `sub_type=invite`. |
|
||||||
|
| `upload_file` | Not supported | OneBot v11 has endpoint-specific file upload extensions but no portable standalone upload action. |
|
||||||
|
| `get_file_url` | Not supported | OneBot v11 file URL resolution is endpoint-specific. Use `call_platform_api("get_image")`, `get_record`, or endpoint extensions when available. |
|
||||||
|
| `mute_member` | Supported | Uses `set_group_ban`. |
|
||||||
|
| `unmute_member` | Supported | Uses `set_group_ban` with duration `0`. |
|
||||||
|
| `kick_member` | Supported | Destructive; test only with disposable members. |
|
||||||
|
| `leave_group` | Supported | Destructive; should run last in live tests. |
|
||||||
|
| `call_platform_api` | Supported | See below. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
`call_platform_api(action, params)` supports:
|
||||||
|
|
||||||
|
- `get_login_info`
|
||||||
|
- `get_status`
|
||||||
|
- `get_version_info`
|
||||||
|
- `get_group_honor_info`
|
||||||
|
- `set_group_card`
|
||||||
|
- `set_group_special_title`
|
||||||
|
- `set_group_admin`
|
||||||
|
- `set_group_whole_ban`
|
||||||
|
- `send_group_forward_msg`
|
||||||
|
- `get_forward_msg`
|
||||||
|
- `get_record`
|
||||||
|
- `get_image`
|
||||||
|
- `can_send_image`
|
||||||
|
- `can_send_record`
|
||||||
|
|
||||||
|
## Message Conversion Notes
|
||||||
|
|
||||||
|
Incoming OneBot segments are converted into common `MessageChain` components before LangBot core/plugin dispatch:
|
||||||
|
|
||||||
|
- `text` -> `Plain`
|
||||||
|
- `at` -> `At` / `AtAll`
|
||||||
|
- `image` -> `Image` or `Face` for OneBot emoji-package images
|
||||||
|
- `record` -> `Voice`
|
||||||
|
- `file` -> `File`
|
||||||
|
- `reply` -> `Quote`
|
||||||
|
- `face`, `rps`, `dice` -> `Face`
|
||||||
|
- unsupported segments -> `Unknown`
|
||||||
|
|
||||||
|
Outgoing `MessageChain` components are converted back into `aiocqhttp.Message` segments. Base64 media strings are normalized to OneBot `base64://...` format.
|
||||||
|
|
||||||
|
## Live Test Record
|
||||||
|
|
||||||
|
The direct live probe is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=/Users/qinjunyan/code/projects/langbot/langbot-plugin-sdk/src \
|
||||||
|
uv run python tests/e2e/live_aiocqhttp_eba_probe.py --host 127.0.0.1 --port 2280
|
||||||
|
```
|
||||||
|
|
||||||
|
It starts the reverse WebSocket adapter directly, records observed EBA events to `data/temp/aiocqhttp_eba_live_probe.jsonl`, waits for a real Matcha or OneBot message, then tries reply/send/get/delete/group/user/platform API calls as far as the endpoint supports them.
|
||||||
|
|
||||||
|
Verified on May 10, 2026 with local Matcha connected to `ws://127.0.0.1:2280/ws`:
|
||||||
|
|
||||||
|
- Real inbound group message converted to `MessageReceivedEvent`.
|
||||||
|
- Real lifecycle connection converted to `PlatformSpecificEvent`.
|
||||||
|
- Real reply API succeeded and rendered a quoted bot reply in Matcha.
|
||||||
|
- Real proactive send API succeeded and rendered a bot group message in Matcha.
|
||||||
|
- Real outgoing component sweep succeeded for text, `At`, `AtAll`, `Face`, and base64 `Image`.
|
||||||
|
- Real `get_message`, `get_group_info`, `get_login_info`, `get_status`, `get_version_info`, `can_send_image`, and `can_send_record` calls succeeded against Matcha.
|
||||||
|
- Unit conversion and API-shape tests passed for `Plain`, `At`, `AtAll`, `Image`, `Voice`, `File`, `Quote`, `Face`, `rps`, `dice`, `Forward`, `Unknown`, private/group message events, delete notices, group join/leave/ban notices, bot mute notices, friend requests, group invites, friend added notices, dispatch specificity, send, reply, delete, forward, get message, group APIs, user APIs, request approval APIs, moderation APIs, leave group, unsupported file APIs, and all declared `call_platform_api` actions.
|
||||||
|
|
||||||
|
Skipped or residual live-test items:
|
||||||
|
|
||||||
|
- `edit_message`: not implemented because OneBot v11 has no standard edit action.
|
||||||
|
- `upload_file` and `get_file_url`: not implemented as common APIs because portable OneBot v11 file upload/download URL semantics are endpoint-specific.
|
||||||
|
- `kick_member` and `leave_group`: destructive; run only with explicit `--destructive` and disposable Matcha/OneBot state.
|
||||||
|
- `group.info_updated`, message reactions, and message edits are not declared because OneBot v11 does not provide standard equivalents for them.
|
||||||
|
- Matcha returned `ActionFailed` for outgoing `File` segment rendering and did not support merged-forward actions in this run. The adapter keeps the conversion/API implementations because they are valid OneBot/NapCat-style capabilities, but the Matcha live probe records them as skipped.
|
||||||
|
- Matcha returned an empty `get_group_member_list` for the test group, so `get_group_member_info`, mute/unmute, kick, and leave were covered by unit/API-shape tests only in this run.
|
||||||
|
|
||||||
|
## Standalone Runtime Plugin E2E Record
|
||||||
|
|
||||||
|
Verified on May 10, 2026 with `EBAEventProbe`, SDK standalone runtime, LangBot `--standalone-runtime`, local Matcha, and group `测试群`.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- Plugin JSONL: `data/temp/aiocqhttp-plugin-e2e-20260510-multiformat.jsonl`
|
||||||
|
|
||||||
|
Observed and verified:
|
||||||
|
|
||||||
|
- A real Matcha group message reached the plugin as `MessageReceived` with `bot_uuid=eba-aiocqhttp-matcha`, `adapter_name=aiocqhttp`, common `Source`/`Plain` message components, common sender, and common group identifiers.
|
||||||
|
- A protocol-level OneBot reverse WebSocket event reached the plugin as `MessageReceived` with a mixed common chain: `Source`, `Plain`, `At`, `Face`, `Image`, `Voice`, `File`, `Quote`, and trailing `Plain`. This proves the real adapter + LangBot + standalone runtime + plugin path for mixed inbound OneBot payloads, but it was not sent through Matcha UI.
|
||||||
|
- SDK API calls succeeded: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage, workspace storage, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`.
|
||||||
|
- Outbound component sweep succeeded for plain text plus `At`/`Face`, `AtAll`, base64 `Image`, and quoted reply.
|
||||||
|
- Common APIs succeeded through the plugin path: `get_message`, `get_user_info`, `get_friend_list`, `get_group_info`, `get_group_list`, `get_group_member_list`, and `get_group_member_info`.
|
||||||
|
- Safe OneBot platform APIs succeeded through `call_platform_api`: `get_login_info`, `get_status`, `get_version_info`, `can_send_image`, and `can_send_record`.
|
||||||
|
|
||||||
|
Documented Matcha limits in this E2E run:
|
||||||
|
|
||||||
|
- Matcha UI did not provide a completed image/file upload/send path for inbound media. The rich inbound media evidence is `plugin-e2e-protocol`, not UI-level media upload evidence.
|
||||||
|
- Outbound `File` failed in Matcha even after the adapter emitted an official `file` segment shape.
|
||||||
|
- Outbound `Forward` failed because Matcha returned unsupported action for merged-forward.
|
||||||
|
- `get_group_honor_info` failed because Matcha returned unsupported action.
|
||||||
|
- Destructive/admin APIs such as mute, unmute, kick, leave, group rename, card/title/admin/whole-ban changes, and request approvals were not run without disposable fixtures.
|
||||||
114
docs/event-based-agents/adapters/dingtalk.md
Normal file
114
docs/event-based-agents/adapters/dingtalk.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# DingTalk EBA Adapter Migration Record
|
||||||
|
|
||||||
|
Status: migrated with partial plugin E2E evidence.
|
||||||
|
|
||||||
|
Adapter directory: `src/langbot/pkg/platform/adapters/dingtalk/`
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
The DingTalk adapter now has an Event-Based Agents adapter package with:
|
||||||
|
|
||||||
|
- `manifest.yaml` for adapter metadata, configuration, events, common APIs, and platform-specific APIs.
|
||||||
|
- `adapter.py` for DingTalk client startup, native callback handling, legacy compatibility, and EBA dispatch.
|
||||||
|
- `event_converter.py` for native DingTalk events to common EBA events.
|
||||||
|
- `message_converter.py` for DingTalk message payloads to/from common `MessageChain` components.
|
||||||
|
- `api_impl.py` for common EBA API implementations.
|
||||||
|
- `platform_api.py` for DingTalk-specific `call_platform_api` actions.
|
||||||
|
|
||||||
|
The legacy DingTalk HTTP client now returns successful JSON response bodies from proactive send methods and raises with response details on non-200 responses.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
|-------|----------|-------|
|
||||||
|
| `client-id` | yes | DingTalk robot/client identifier. |
|
||||||
|
| `client-secret` | yes | DingTalk client secret. |
|
||||||
|
| `robot-code` | yes | Robot code used for send APIs. |
|
||||||
|
| `robot-name` | no | Used for bot mention/self filtering and display. |
|
||||||
|
| `encrypt-key` | no | DingTalk callback encryption key when configured. |
|
||||||
|
| `verification-token` | no | DingTalk callback verification token when configured. |
|
||||||
|
|
||||||
|
## Supported Events
|
||||||
|
|
||||||
|
| Event | Support | Evidence |
|
||||||
|
|-------|---------|----------|
|
||||||
|
| `message.received` | implemented | `plugin-e2e-ui` private text and emoji-as-text. |
|
||||||
|
| `platform.specific` | implemented | Not reproduced in the latest UI run. |
|
||||||
|
|
||||||
|
## Receive Components
|
||||||
|
|
||||||
|
| Component | Support | Evidence |
|
||||||
|
|-----------|---------|----------|
|
||||||
|
| `Source` | supported | `plugin-e2e-ui` private message. |
|
||||||
|
| `Plain` | supported | `plugin-e2e-ui` private text. DingTalk emoji currently arrives as plain text such as `[smile]`. |
|
||||||
|
| `At` | converter path | Group trigger was not completed in the latest run. |
|
||||||
|
| `AtAll` | fallback/send-side only | Not completed inbound. |
|
||||||
|
| `Image` | supported | Real DingTalk Mac private-chat image upload reached the plugin as common `Image`. |
|
||||||
|
| `Voice` | converter path | Real UI inbound voice was not completed. |
|
||||||
|
| `File` | supported | Real DingTalk Mac private-chat file upload reached the plugin as common `File`. |
|
||||||
|
| `Quote` | converter path | Real UI inbound quote was not completed. |
|
||||||
|
| `Face` | not native common mapping | DingTalk emoji was observed as `Plain`, not `Face`. |
|
||||||
|
| `Forward` | not-supported inbound | DingTalk does not expose a portable structured forward event in this adapter. |
|
||||||
|
|
||||||
|
## Send Components
|
||||||
|
|
||||||
|
| Component | Support | Evidence |
|
||||||
|
|-----------|---------|----------|
|
||||||
|
| `Plain` | supported | `plugin-e2e-outbound`. |
|
||||||
|
| `At` | supported or text fallback | `plugin-e2e-outbound`. |
|
||||||
|
| `AtAll` | fallback | `plugin-e2e-outbound`. |
|
||||||
|
| `Image` | supported | `plugin-e2e-outbound`. |
|
||||||
|
| `File` | supported | `plugin-e2e-outbound`. |
|
||||||
|
| `Quote` | fallback | `plugin-e2e-outbound`. |
|
||||||
|
| `Face` | fallback | `plugin-e2e-outbound` as text fallback. |
|
||||||
|
| `Forward` | flattened fallback | `plugin-e2e-outbound`. |
|
||||||
|
| `Voice` | fallback/endpoint-dependent | Not separately verified as a native DingTalk voice send. |
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Support | Notes |
|
||||||
|
|-----|---------|-------|
|
||||||
|
| `send_message` | supported | Verified through `EBAEventProbe`. |
|
||||||
|
| `reply_message` | supported | Verified through quoted/fallback send path. |
|
||||||
|
| `get_message` | cache-backed | Requires the message to have been observed by this adapter process. |
|
||||||
|
| `get_group_info` | cache-backed/API-backed where available | Group path not completed in latest UI run. |
|
||||||
|
| `get_group_list` | supported where DingTalk API allows | Limited live coverage. |
|
||||||
|
| `get_group_member_info` | supported where DingTalk API allows | Limited live coverage. |
|
||||||
|
| `get_user_info` | supported | Private sender path verified. |
|
||||||
|
| `get_friend_list` | limited | DingTalk does not expose a portable friend-list equivalent. |
|
||||||
|
| `get_file_url` | supported with media/file identifiers | Real inbound file yielded a platform file URL in the converted `File` component. |
|
||||||
|
| `call_platform_api` | supported | Safe action `check_access_token` verified. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
| Action | Support | Evidence |
|
||||||
|
|--------|---------|----------|
|
||||||
|
| `check_access_token` | supported | `plugin-e2e`. |
|
||||||
|
| `refresh_access_token` | supported | Implemented; not separately reproduced in the latest plugin run. |
|
||||||
|
| `get_file_url` | supported | Real inbound file yielded a platform file URL in the converted `File` component. |
|
||||||
|
| `get_audio_base64` | supported | Needs real inbound audio/media ID. |
|
||||||
|
| `download_image_base64` | supported | Real inbound image reached the plugin as `Image`; separate image-download API replay was not completed. |
|
||||||
|
|
||||||
|
## End-to-End Evidence
|
||||||
|
|
||||||
|
Evidence files:
|
||||||
|
|
||||||
|
- Text/API/component JSONL: `data/temp/dingtalk-plugin-e2e-20260510-rerun.jsonl`
|
||||||
|
- Real UI inbound media JSONL: `data/temp/dingtalk-plugin-e2e-media-ui.jsonl`
|
||||||
|
|
||||||
|
Verified:
|
||||||
|
|
||||||
|
- DingTalk Mac private chat in the `LangBot Team` organization produced `MessageReceived` through LangBot standalone runtime and `EBAEventProbe`.
|
||||||
|
- The common chain was `Source + Plain` for normal text.
|
||||||
|
- DingTalk emoji was received as `Source + Plain`, not common `Face`.
|
||||||
|
- Real DingTalk Mac private-chat image upload was received as `Source + Image`.
|
||||||
|
- Real DingTalk Mac private-chat file upload was received as `Source + File`.
|
||||||
|
- The plugin sent outbound text, mention/fallback, image, quote/fallback, file, and forward/fallback messages visible in DingTalk.
|
||||||
|
- The plugin called safe SDK and DingTalk platform APIs.
|
||||||
|
|
||||||
|
Not completed:
|
||||||
|
|
||||||
|
- Real UI inbound voice.
|
||||||
|
- Real UI inbound quote.
|
||||||
|
- Group trigger with a real robot mention.
|
||||||
|
- Destructive or organization-mutating APIs.
|
||||||
147
docs/event-based-agents/adapters/discord.md
Normal file
147
docs/event-based-agents/adapters/discord.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Discord EBA Adapter
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Discord has been migrated from the legacy source adapter:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/langbot/pkg/platform/sources/discord.py
|
||||||
|
src/langbot/pkg/platform/sources/discord.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
EBA adapter directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/langbot/pkg/platform/adapters/discord/
|
||||||
|
├── adapter.py
|
||||||
|
├── api_impl.py
|
||||||
|
├── event_converter.py
|
||||||
|
├── manifest.yaml
|
||||||
|
├── message_converter.py
|
||||||
|
├── platform_api.py
|
||||||
|
├── types.py
|
||||||
|
└── voice.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter is registered as `discord-eba`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Default | Description |
|
||||||
|
|-------|----------|---------|-------------|
|
||||||
|
| `client_id` | Yes | `""` | Discord application client ID. |
|
||||||
|
| `token` | Yes | `""` | Discord bot token. |
|
||||||
|
|
||||||
|
The bot needs gateway permissions and intents for the target test server. Message Content intent is required for message bodies, Server Members intent is required for member APIs/events, and reaction events require the Reactions intent and channel permissions.
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
Discord declares these EBA events:
|
||||||
|
|
||||||
|
- `message.received`
|
||||||
|
- `message.edited`
|
||||||
|
- `message.deleted`
|
||||||
|
- `message.reaction`
|
||||||
|
- `group.member_joined`
|
||||||
|
- `group.member_left`
|
||||||
|
- `group.member_banned`
|
||||||
|
- `bot.invited_to_group`
|
||||||
|
- `bot.removed_from_group`
|
||||||
|
- `platform.specific`
|
||||||
|
|
||||||
|
Discord-specific events that do not map cleanly to common events should be surfaced as `platform.specific`.
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Status | Notes |
|
||||||
|
|-----|-----------------|-------|
|
||||||
|
| `send_message` | Supported | Supports text, image, file, and mixed message chains through Discord messages and attachments. |
|
||||||
|
| `reply_message` | Supported | Uses Discord message references when replying to a received EBA message event. |
|
||||||
|
| `edit_message` | Supported | Bot can edit its own messages. File edits are implemented by clearing old attachments and sending replacement files when needed. |
|
||||||
|
| `delete_message` | Supported | Requires message management permissions for non-bot messages. |
|
||||||
|
| `forward_message` | Emulated | Discord has no native forward API; the adapter copies content and attachments. |
|
||||||
|
| `get_group_info` | Supported | Maps Discord guild metadata to EBA group info. |
|
||||||
|
| `get_group_member_list` | Supported | Requires member cache or the Server Members intent/fetch permission. |
|
||||||
|
| `get_group_member_info` | Supported | Maps Discord roles/permissions into EBA member roles. |
|
||||||
|
| `get_user_info` | Supported | Uses Discord user fetch/cache. |
|
||||||
|
| `upload_file` | Not supported | Discord uploads files as message attachments; standalone upload raises `NotSupportedError`. |
|
||||||
|
| `get_file_url` | Supported | Discord attachment URLs are already downloadable URLs, so the adapter returns the input URL. |
|
||||||
|
| `mute_member` | Supported where possible | Uses Discord timeout API and requires guild moderation permission. |
|
||||||
|
| `unmute_member` | Supported where possible | Clears timeout and requires guild moderation permission. |
|
||||||
|
| `kick_member` | Supported | Destructive; test only with a disposable account/bot. |
|
||||||
|
| `leave_group` | Supported | Bot leaves a guild; destructive and should run last. |
|
||||||
|
| `call_platform_api` | Supported | Discord-specific actions live here. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
`call_platform_api(action, params)` supports:
|
||||||
|
|
||||||
|
- `get_channel`
|
||||||
|
- `get_guild`
|
||||||
|
- `get_guild_channels`
|
||||||
|
- `get_guild_roles`
|
||||||
|
- `create_invite`
|
||||||
|
- `pin_message`
|
||||||
|
- `unpin_message`
|
||||||
|
- `add_reaction`
|
||||||
|
- `remove_reaction`
|
||||||
|
- `typing`
|
||||||
|
|
||||||
|
Voice helpers are intentionally kept Discord-specific:
|
||||||
|
|
||||||
|
- `join_voice_channel`
|
||||||
|
- `leave_voice_channel`
|
||||||
|
- `get_voice_connection_status`
|
||||||
|
- `list_active_voice_connections`
|
||||||
|
- `get_voice_channel_info`
|
||||||
|
|
||||||
|
## Live Test Record
|
||||||
|
|
||||||
|
The live probe is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python tests/e2e/live_discord_eba_probe.py --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified on May 7, 2026 with a newly created Discord application/bot named `LangBot EBA Test 0507`, the LangBot Discord server, and the `#🐞-debugging` channel:
|
||||||
|
|
||||||
|
- SDK standalone runtime started with WebSocket control/debug ports, and the `EBAEventProbe` plugin connected through `lbp run`.
|
||||||
|
- Plugin runtime received real Discord events through LangBot: `BotInvitedToGroup`, `MessageReceived`, `MessageReactionReceived` add/remove, `MessageEdited`, and `MessageDeleted`.
|
||||||
|
- Plugin runtime API calls succeeded through the standalone runtime: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage APIs, workspace storage APIs, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`.
|
||||||
|
- Direct live adapter probe observed `message.received`, `message.edited`, `message.deleted`, and `bot.removed_from_group`.
|
||||||
|
- Message APIs verified: send, reply, edit, delete, forward, text/image/file mixed message chains.
|
||||||
|
- User and guild APIs verified: `get_user_info`, `get_group_info`, `get_group_member_list`, `get_group_member_info`.
|
||||||
|
- Platform-specific APIs verified: `get_channel`, `get_guild`, `get_guild_channels`, `get_guild_roles`, `create_invite`, `typing`, `pin_message`, `unpin_message`, `add_reaction`, `remove_reaction`.
|
||||||
|
- Unsupported API behavior verified: `upload_file` raises `NotSupportedError`.
|
||||||
|
- Destructive API verified at the end: `leave_group`, which emitted `bot.removed_from_group`.
|
||||||
|
|
||||||
|
Not verified in the shared LangBot server live run: `mute_member`, `unmute_member`, and `kick_member`, because the run did not use a disposable target member. They are implemented through Discord timeout/kick APIs and should only be exercised against a disposable account or bot.
|
||||||
|
|
||||||
|
The test fixed one real test-fixture issue: `EBAEventProbe` previously assumed `get_bots()` returned UUID strings. The current standalone runtime returns bot dictionaries, so the probe now selects an enabled bot dictionary and passes its `uuid` to `get_bot_info` and `send_message`. The probe also now subscribes to `MessageDeleted`.
|
||||||
|
|
||||||
|
## Standalone Runtime Plugin E2E Record
|
||||||
|
|
||||||
|
Verified again on May 10, 2026 with SDK standalone runtime, LangBot `--standalone-runtime`, Discord web client, the LangBot server, and `#🐞-debugging`.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- Main plugin JSONL: `data/temp/discord-plugin-e2e-20260510-final.jsonl`
|
||||||
|
- LangBot runtime log: `data/temp/discord-langbot-e2e-20260510-rerun.log`
|
||||||
|
|
||||||
|
Observed and verified:
|
||||||
|
|
||||||
|
- A newly invited Discord bot connected to the LangBot server and received a real web-client message in `#🐞-debugging`.
|
||||||
|
- `MessageReceived` reached the plugin with `bot_uuid=eba-discord-live`, `adapter_name=discord`, common `Source`/`Plain` message components, common `User`, and common `UserGroup` for the guild.
|
||||||
|
- SDK API calls succeeded: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage, workspace storage, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`.
|
||||||
|
- Outbound component sweep succeeded: plain text plus user mention, `AtAll`/`@everyone`, base64 image, quoted reply, file attachment, and flattened forward fallback.
|
||||||
|
- Common APIs succeeded: `get_user_info`, `get_group_info`, `get_group_member_list`, and `get_group_member_info`.
|
||||||
|
- Discord platform APIs succeeded through `call_platform_api`: `get_channel`, `typing`, `get_guild`, `get_guild_channels`, and `get_guild_roles`.
|
||||||
|
|
||||||
|
Documented limits in this E2E run:
|
||||||
|
|
||||||
|
- Real Discord UI inbound attachment/image/file, reply/quote, and fresh mention-chain messages were not completed in the plugin E2E evidence. Outbound image/file attachments from the bot do not prove inbound attachment conversion.
|
||||||
|
- A later May 10 UI retry could write text into the Discord message box, but the client kept the send button disabled and did not send the message, so it produced no new plugin evidence.
|
||||||
|
- `get_message`, `get_friend_list`, and `get_group_list` are not supported by this Discord adapter.
|
||||||
|
- Destructive moderation and guild-leave APIs were not repeated against the shared LangBot server.
|
||||||
|
- Native Discord voice is not represented as common `Voice`; audio-like payloads are treated as file attachments.
|
||||||
|
- `create_invite`, pin/unpin, and reaction mutation were covered by prior direct live probes but were not repeated by the final plugin run to avoid extra shared-server side effects.
|
||||||
108
docs/event-based-agents/adapters/kook.md
Normal file
108
docs/event-based-agents/adapters/kook.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# KOOK EBA Adapter
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
KOOK has been migrated to the EBA adapter directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/langbot/pkg/platform/adapters/kook/
|
||||||
|
├── adapter.py
|
||||||
|
├── api_impl.py
|
||||||
|
├── event_converter.py
|
||||||
|
├── manifest.yaml
|
||||||
|
├── message_converter.py
|
||||||
|
├── platform_api.py
|
||||||
|
└── types.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter is registered as `kook-eba`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Default | Description |
|
||||||
|
|-------|----------|---------|-------------|
|
||||||
|
| `token` | Yes | `""` | KOOK bot token. |
|
||||||
|
| `enable-stream-reply` | Yes | `false` | Reserved for shared platform configuration compatibility. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
| Event | Evidence | Notes |
|
||||||
|
|-------|----------|-------|
|
||||||
|
| `message.received` | `plugin-e2e-ui` | Real KOOK UI channel message reached `EBAEventProbe` as `MessageReceivedEvent`. |
|
||||||
|
| `platform.specific` | `plugin-e2e-ui` | KOOK gateway event without a common EBA mapping reached `EBAEventProbe` as `PlatformSpecificEventReceived`. |
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Evidence | Notes |
|
||||||
|
|-----|----------|-------|
|
||||||
|
| `send_message` | `plugin-e2e-outbound` | Probe plugin sent channel messages through SDK `send_message`; KOOK returned message IDs. |
|
||||||
|
| `reply_message` | `unit` | Supports `reply_msg_id` and optional quoted replies when the source message ID is available. |
|
||||||
|
| `get_message` | `plugin-e2e-outbound` | Probe plugin fetched the cached triggering message. |
|
||||||
|
| `get_group_info` | `plugin-e2e-outbound` | Probe plugin received cached KOOK channel info. |
|
||||||
|
| `get_group_list` | `plugin-e2e-outbound` | Probe plugin received cached channel/group entities observed by the adapter. |
|
||||||
|
| `get_group_member_info` | `plugin-e2e-outbound` | Probe plugin received cached sender info as a group member. |
|
||||||
|
| `get_user_info` | `plugin-e2e-outbound` | Probe plugin received cached sender user info. |
|
||||||
|
| `get_friend_list` | `plugin-e2e-outbound` | Probe plugin received cached users. |
|
||||||
|
| `upload_file` | `unit` | Uses KOOK `asset/create` and returns URL/ID. |
|
||||||
|
| `get_file_url` | `unit` | KOOK media IDs are URL-like in the adapter path; returns the ID unchanged. |
|
||||||
|
| `delete_message` | `unit` | Calls KOOK delete endpoints. Live permission verification is still required. |
|
||||||
|
| `forward_message` | `plugin-e2e-outbound` | Probe plugin sent flattened forward content through SDK `send_message`. |
|
||||||
|
| `call_platform_api` | `plugin-e2e-outbound` | Probe plugin called safe KOOK platform-specific APIs through SDK `call_platform_api`. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
| Action | Evidence | Notes |
|
||||||
|
|--------|----------|-------|
|
||||||
|
| `get_current_user` | `plugin-e2e-outbound` | Probe plugin called `user/me`. |
|
||||||
|
| `get_user` | `plugin-e2e-outbound` | Probe plugin called `user/view` for the triggering sender. |
|
||||||
|
| `get_channel` | `plugin-e2e-outbound` | Probe plugin called `channel/view` for the triggering channel. |
|
||||||
|
| `get_guild` | `plugin-e2e-outbound` | Probe plugin called `guild/view`; gateway URLs redact token query values. |
|
||||||
|
| `get_gateway` | `plugin-e2e-outbound` | Probe plugin called `gateway/index`; returned token query values are redacted. |
|
||||||
|
| `send_direct_message` | `unit` | Calls `direct-message/create`. |
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Component | Receive Evidence | Send Evidence | Notes |
|
||||||
|
|-----------|------------------|---------------|-------|
|
||||||
|
| `Source` | `plugin-e2e-ui` | N/A | KOOK message ID and timestamp are preserved. |
|
||||||
|
| `Plain` | `plugin-e2e-ui` | `plugin-e2e-outbound` | Text and KMarkdown are represented as plain common text. |
|
||||||
|
| `At` | `plugin-e2e-ui` | `plugin-e2e-outbound` | KOOK `(met)<id>(met)` mentions map to common `At`. |
|
||||||
|
| `AtAll` | `unit` | `plugin-e2e-outbound` | KOOK `(met)all(met)` maps to common `AtAll`; real inbound UI AtAll was not tested. |
|
||||||
|
| `Image` | `unit` | `unit` | URL/image ID based path only; live rendering still needs verification. |
|
||||||
|
| `Voice` | `unit` | `unit` | URL based path only; live rendering still needs verification. |
|
||||||
|
| `File` | `unit` | `unit` | URL based path only; upload API is exposed separately. |
|
||||||
|
| `Forward` | `unit` | `unit` | Outbound forwards are flattened; inbound structured forwards are not exposed by current legacy implementation. |
|
||||||
|
| `Unknown` | `unit` | N/A | Unsupported KOOK message types become `Unknown` or `PlatformSpecificEvent`. |
|
||||||
|
|
||||||
|
## Acceptance Record
|
||||||
|
|
||||||
|
Test date: June 4, 2026.
|
||||||
|
|
||||||
|
Plugin E2E verified on June 4, 2026 with `EBAEventProbe`, SDK standalone runtime, KOOK WebSocket adapter, and a real KOOK channel UI message.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- JSONL: `data/temp/kook_eba_plugin_probe.jsonl`
|
||||||
|
- Plugin log: `data/logs/eba-probe-kook.log`
|
||||||
|
|
||||||
|
Observed and verified:
|
||||||
|
|
||||||
|
- A real KOOK UI channel message reached the plugin as `MessageReceived` with `bot_uuid=7ab5b065-6e4e-4def-95f0-3c265366e26f`, `adapter_name=kook`, common sender/group/chat fields, and common `MessageChain` components.
|
||||||
|
- KOOK gateway-specific event reached the plugin as `PlatformSpecificEventReceived`.
|
||||||
|
- Probe plugin called SDK `send_message`; KOOK returned message IDs for text, At, AtAll, image URL/base64 fallback path, quote fallback, file fallback, and flattened forward cases.
|
||||||
|
- Probe plugin called common API methods through the SDK path: `get_message`, `get_user_info`, `get_friend_list`, `get_group_info`, `get_group_list`, and `get_group_member_info`.
|
||||||
|
- Probe plugin called safe KOOK platform-specific APIs through SDK `call_platform_api`: `get_current_user`, `get_user`, `get_channel`, `get_gateway`, and `get_guild`.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/unit_tests/platform/test_kook_eba_adapter.py
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Blocked or partial items:
|
||||||
|
|
||||||
|
- `plugin-e2e-ui` inbound coverage for image, file, voice, AtAll, quote, and forward.
|
||||||
|
- `plugin-e2e-outbound` visual verification in KOOK UI for image/file/voice rendering. KOOK returned message IDs, but UI inspection was not performed in this run.
|
||||||
|
- `reply_message` and `delete_message` live permission verification.
|
||||||
|
- Destructive or permission-sensitive APIs were not declared beyond delete; KOOK mute/kick/leave remain explicit `NotSupportedError` paths until a safe fixture is available.
|
||||||
135
docs/event-based-agents/adapters/lark.md
Normal file
135
docs/event-based-agents/adapters/lark.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Lark / Feishu EBA Adapter Migration Record
|
||||||
|
|
||||||
|
Status: migrated with unit coverage and partial live plugin E2E. WebSocket text reached the standalone runtime once in the LangBot organization test app, but the latest real UI image/file inbound attempts did not reach the local adapter log, so media receive is not release-complete yet.
|
||||||
|
|
||||||
|
Adapter directory: `src/langbot/pkg/platform/adapters/lark/`
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
The Lark/Feishu adapter now has an Event-Based Agents adapter package with:
|
||||||
|
|
||||||
|
- `manifest.yaml` for adapter metadata, configuration, events, common APIs, platform-specific APIs, app type, and communication mode.
|
||||||
|
- `adapter.py` for self-built/store app token handling, WebSocket long connection startup, Webhook callback handling, card feedback, streaming-card replies, and EBA dispatch.
|
||||||
|
- `event_converter.py` for native Feishu events to common EBA events.
|
||||||
|
- `message_converter.py` for Feishu text/post/image/file/audio payloads to/from common `MessageChain` components.
|
||||||
|
- `api_impl.py` for common EBA API implementations.
|
||||||
|
- `platform_api.py` for Feishu-specific `call_platform_api` actions.
|
||||||
|
|
||||||
|
The legacy `lark` adapter remains available while the EBA adapter is registered separately as `lark-eba`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
|-------|----------|-------|
|
||||||
|
| `app_id` | yes | Feishu/Lark application App ID. |
|
||||||
|
| `app_secret` | yes | Feishu/Lark application App Secret. |
|
||||||
|
| `bot_name` | yes | Must match the bot name so group mentions can be recognized. |
|
||||||
|
| `enable-webhook` | yes | `false` uses WebSocket long connection; `true` uses Request URL/Webhook callbacks. |
|
||||||
|
| `webhook_url` | no | Generated callback URL for Webhook mode. |
|
||||||
|
| `encrypt-key` | no | Webhook decrypt key when event encryption is enabled. |
|
||||||
|
| `enable-stream-reply` | yes | Enables streaming replies through an updating Feishu card. |
|
||||||
|
| `app_type` | no | `self` for self-built apps; `isv` for store apps. |
|
||||||
|
| `bot_added_welcome` | no | Optional group welcome message sent after bot-added events. |
|
||||||
|
|
||||||
|
## Application And Communication Modes
|
||||||
|
|
||||||
|
| Mode | Support | Implementation |
|
||||||
|
|------|---------|----------------|
|
||||||
|
| Self-built application | implemented | Uses standard app credentials and tenant token behavior from the Feishu SDK client. |
|
||||||
|
| Store application | implemented | Builds an ISV client, requests app tickets, and resolves app/tenant access tokens with per-tenant caching. |
|
||||||
|
| WebSocket long connection | implemented | Registers `im.message.receive_v1` and card-action callbacks through `lark_oapi.ws.Client`. |
|
||||||
|
| Webhook Request URL | implemented | Handles URL verification, encrypted payloads, message events, app-ticket events, bot-added events, and card-action feedback. |
|
||||||
|
|
||||||
|
## Supported Events
|
||||||
|
|
||||||
|
| Event | Support | Evidence |
|
||||||
|
|-------|---------|----------|
|
||||||
|
| `message.received` | implemented | Unit coverage for private and group native events to common EBA events. |
|
||||||
|
| `bot.invited_to_group` | implemented | Webhook bot-added event maps to common bot invite event and optional welcome send. |
|
||||||
|
| `platform.specific` | implemented | Unknown callback events are preserved as `platform.specific`. |
|
||||||
|
| `FeedbackEvent` | compatibility event | Card button feedback is still dispatched through the existing SDK `FeedbackEvent` type. |
|
||||||
|
|
||||||
|
## Receive Components
|
||||||
|
|
||||||
|
| Component | Support | Evidence |
|
||||||
|
|-----------|---------|----------|
|
||||||
|
| `Source` | supported | Unit coverage; live private text evidence. |
|
||||||
|
| `Plain` | supported | Text and post payloads convert to common text; live private text evidence. |
|
||||||
|
| `At` | supported | Feishu mentions map to common `At` with user ID and display name. |
|
||||||
|
| `AtAll` | supported | `user_id=all` maps to common `AtAll`. |
|
||||||
|
| `Image` | supported | Image payloads download through message resource API and map to common `Image`; real UI image send attempted, but not observed in local plugin evidence yet. |
|
||||||
|
| `Voice` | supported | Audio payloads download through message resource API and map to common `Voice`. |
|
||||||
|
| `File` | supported | File payloads download through message resource API and map to common `File`; real UI file send attempted, but not observed in local plugin evidence yet. |
|
||||||
|
| `Quote` | supported | Parent/thread reply lookup maps quoted content into common `Quote`. |
|
||||||
|
| `Face` | not native common mapping | Feishu emoji/stickers are not exposed as a portable common `Face` component here. |
|
||||||
|
| `Forward` | not-supported inbound | Feishu does not expose a portable structured forward event in this adapter. |
|
||||||
|
|
||||||
|
## Send Components
|
||||||
|
|
||||||
|
| Component | Support | Evidence |
|
||||||
|
|-----------|---------|----------|
|
||||||
|
| `Plain` | supported | Unit coverage; sends Feishu `text`. |
|
||||||
|
| `At` | supported | Unit coverage; sends Feishu `post` at element. |
|
||||||
|
| `AtAll` | supported | Unit coverage; sends Feishu `post` at-all element. |
|
||||||
|
| `Image` | supported | Uploads image resource and sends Feishu `image`. |
|
||||||
|
| `Voice` | supported | Uploads OPUS/audio resource and sends Feishu `audio`. |
|
||||||
|
| `File` | supported | Uploads file resource and sends Feishu `file`. |
|
||||||
|
| `Quote` | supported/fallback | Sends quote marker plus origin content. |
|
||||||
|
| `Face` | not-supported | No portable send mapping. |
|
||||||
|
| `Forward` | flattened fallback | Flattens forward nodes into text/media messages. |
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Support | Notes |
|
||||||
|
|-----|---------|-------|
|
||||||
|
| `send_message` | supported | Supports private/open_id and group/chat_id targets; live plugin outbound component sweep produced visible Feishu messages. |
|
||||||
|
| `reply_message` | supported | Replies to the source Feishu message; fixed to recover the native Feishu message ID from legacy-wrapped source events. |
|
||||||
|
| `get_message` | cache-backed/API-backed | Returns cached inbound event where possible and converts uncached Feishu message API items into common `MessageReceivedEvent`. |
|
||||||
|
| `get_group_info` | supported | Uses cached group or Feishu chat metadata. |
|
||||||
|
| `get_group_member_info` | limited | Uses cached user data when available. |
|
||||||
|
| `get_user_info` | limited | Uses cached user data when available. |
|
||||||
|
| `get_file_url` | limited | Returns `file://` paths from downloaded inbound resources; remote Feishu resource download uses platform-specific API params. |
|
||||||
|
| `call_platform_api` | supported | See below. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
| Action | Support | Evidence |
|
||||||
|
|--------|---------|----------|
|
||||||
|
| `check_tenant_access_token` | supported | Unit coverage. |
|
||||||
|
| `refresh_app_access_token` | supported | Store-app token path implemented. |
|
||||||
|
| `refresh_tenant_access_token` | supported | Store-app tenant token path implemented. |
|
||||||
|
| `get_chat` | supported | Feishu chat metadata API wrapper. |
|
||||||
|
| `get_message` | supported | Feishu message API wrapper with JSON-safe return values for plugin calls. |
|
||||||
|
| `get_message_resource` | supported | Feishu message resource download wrapper. |
|
||||||
|
|
||||||
|
## End-to-End Evidence
|
||||||
|
|
||||||
|
Current code-level evidence:
|
||||||
|
|
||||||
|
- `tests/unit_tests/platform/test_lark_eba_adapter.py`
|
||||||
|
- `PYTHONPATH=../langbot-plugin-sdk/src uv run pytest tests/unit_tests/platform/test_lark_eba_adapter.py -q`
|
||||||
|
|
||||||
|
Live evidence collected on May 11, 2026:
|
||||||
|
|
||||||
|
- Standalone runtime: `uv run lbp rt --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check`
|
||||||
|
- LangBot: `uv run main.py --standalone-runtime --debug`
|
||||||
|
- Plugin: `LangBot__EBAEventProbe`
|
||||||
|
- Feishu org/app: LangBot organization, `LangBotDev` private chat.
|
||||||
|
- Observed plugin JSONL: one private `MessageReceived` event with `Source + Plain`; plugin API probe then exercised bot discovery, bot info, `send_message`, outbound component sweep, storage/list APIs, and safe platform API calls.
|
||||||
|
- Real UI sends attempted after the fixes: private text, local file, and image/video image upload. These appeared in the Feishu client but did not append new `EBAEventProbe` records in the local JSONL during this run.
|
||||||
|
- Fixes from live testing: reply path now extracts the native Feishu `message_id` from legacy-wrapped source events; WebSocket callbacks are scheduled onto the adapter event loop instead of assuming the SDK callback has a running asyncio loop; platform API results are converted to JSON-safe values.
|
||||||
|
|
||||||
|
Live E2E items still required before marking release-complete:
|
||||||
|
|
||||||
|
- WebSocket self-built app in LangBot organization: repeat private text after callback-loop fix, plus private image/file/audio and group mention message received by `EBAEventProbe`.
|
||||||
|
- Webhook self-built app in LangBot organization: URL verification plus text/image/file message received by `EBAEventProbe`.
|
||||||
|
- Store app token path: at least token acquisition/tenant-token safe API through `call_platform_api`; full message E2E if a LangBot organization store-app fixture is available.
|
||||||
|
- Outbound component sweep: text, mention, at-all, image, file, voice where Feishu accepts the fixture, quote/fallback, and forward/fallback.
|
||||||
|
- Safe platform API sweep: token check, chat metadata, message lookup, and message resource download using real inbound IDs.
|
||||||
|
|
||||||
|
## Known Limits
|
||||||
|
|
||||||
|
- Store-app live E2E requires a real ISV app ticket/tenant installation fixture.
|
||||||
|
- Current LangBot organization WebSocket run connected successfully but did not deliver the latest UI-sent image/file attempts to local plugin evidence; this blocks release-complete media acceptance.
|
||||||
|
- Feishu native emoji/sticker semantics are not represented as common `Face`.
|
||||||
|
- Destructive org or chat mutations are not declared in this adapter.
|
||||||
101
docs/event-based-agents/adapters/officialaccount.md
Normal file
101
docs/event-based-agents/adapters/officialaccount.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# OfficialAccount EBA Adapter
|
||||||
|
|
||||||
|
Adapter directory: `src/langbot/pkg/platform/adapters/officialaccount/`
|
||||||
|
|
||||||
|
Manifest name: `officialaccount-eba`
|
||||||
|
|
||||||
|
Status: partial migration. Unit/API-shape coverage is present, and private text `plugin-e2e-ui` plus safe API evidence has been verified against the `dev.rockchin.top` Official Account fixture. Proactive outbound `send_message` remains not supported by this adapter because WeChat Official Account replies must be tied to inbound webhook windows.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `webhook_url` | no | Generated by LangBot and copied into the Official Account callback settings. |
|
||||||
|
| `token` | yes | WeChat callback token. |
|
||||||
|
| `EncodingAESKey` | yes | WeChat message encryption key. |
|
||||||
|
| `AppID` | yes | Official Account app ID. |
|
||||||
|
| `AppSecret` | yes | Official Account app secret. |
|
||||||
|
| `Mode` | yes | `drop` waits for an in-callback reply; `passive` returns the loading text first and queues the answer for the user's next message. |
|
||||||
|
| `LoadingMessage` | no | Only used by `passive` mode. |
|
||||||
|
| `api_base_url` | no | Optional API base URL for proxy deployments. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
| Event | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `message.received` | plugin-e2e-ui, unit | Text UI message verified through WeChat Official Account on `dev.rockchin.top`; image and voice webhook payloads are covered by unit tests. |
|
||||||
|
| `platform.specific` | unit | Subscribe/menu/etc. native events are emitted as structured `PlatformSpecificEvent`. |
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `reply_message` | unit | Queues/passively returns text through the inbound webhook source event. |
|
||||||
|
| `get_message` | plugin-e2e-ui, unit | Cached inbound message retrieved by `EBAEventProbe` platform API sweep. |
|
||||||
|
| `get_user_info` | plugin-e2e-ui, unit | Cached inbound sender retrieved by `EBAEventProbe` platform API sweep. |
|
||||||
|
| `get_friend_list` | plugin-e2e-ui, unit | Cached inbound sender list retrieved by `EBAEventProbe` platform API sweep. |
|
||||||
|
| `call_platform_api` | plugin-e2e-ui, unit | Safe diagnostic actions verified through `get_mode` and `get_cached_response_status`. |
|
||||||
|
| `send_message` | not-supported | Official Account customer-service proactive messaging is not implemented by the existing SDK adapter; only webhook reply is supported here. |
|
||||||
|
|
||||||
|
## Platform APIs
|
||||||
|
|
||||||
|
| Action | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `get_mode` | plugin-e2e-ui, unit | Returned `{"mode": "drop", "longer_response": false}` in live probe. |
|
||||||
|
| `get_cached_response_status` | plugin-e2e-ui, unit | Returned `{"pending": false}` in live probe. |
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Receive Component | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Source` | plugin-e2e-ui, unit | Uses `MsgId` and `CreateTime`; live UI text message included `Source`. |
|
||||||
|
| `Plain` | plugin-e2e-ui, unit | Live UI text message mapped to `Plain`. |
|
||||||
|
| `Image` | unit | `PicUrl` and `MediaId` map to common `Image`. |
|
||||||
|
| `Voice` | unit | `MediaId` maps to common `Voice`. |
|
||||||
|
| `Unknown` | unit | Unsupported message/event types do not crash. |
|
||||||
|
| `At`, `AtAll`, `File`, `Quote`, `Face`, `Forward`, mixed chain | not-supported | WeChat Official Account inbound webhook payloads used by the current SDK do not expose these as common structured components. |
|
||||||
|
|
||||||
|
| Send Component | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Plain` | unit | Sent as webhook reply text. |
|
||||||
|
| `Image`, `Voice`, `File`, `Quote`, `At`, `AtAll`, `Face`, `Forward`, mixed chain | not-supported | Existing SDK reply path is text XML only; non-text components degrade to readable placeholders in tests and are not declared as supported outbound components. |
|
||||||
|
|
||||||
|
## Verification Record
|
||||||
|
|
||||||
|
Test date: 2026-05-28
|
||||||
|
|
||||||
|
Endpoint/simulator: `dev.rockchin.top` with WeChat desktop client and a real subscribed Official Account conversation. The running EBA test stack used SDK standalone runtime ports `5400/5401`, LangBot from `/home/wgc/LangBotxg/LangBotEbaTest`, and `EBAEventProbe`.
|
||||||
|
|
||||||
|
Verified UI message: `EBA officialaccount single probe 2026-05-28 16:53`
|
||||||
|
|
||||||
|
Observed event/API evidence:
|
||||||
|
|
||||||
|
- `MessageReceived`: `bot_uuid=d7c46880-a9f8-431a-9172-5d3e0d663dbc`, `adapter_name=officialaccount-eba`, `chat_type=private`, `chat_id=ovH9L7OW6hNpWZWvp_NMmypVh26w`, `message_chain=[Source, Plain]`.
|
||||||
|
- Common safe APIs through probe platform sweep: `get_message`, `get_user_info`, `get_friend_list`.
|
||||||
|
- Platform APIs through `call_platform_api`: `get_mode`, `get_cached_response_status`.
|
||||||
|
- `send_message` and outbound component sweep returned explicit `NotSupportedError: send_message:official_account_requires_inbound_webhook_reply`, as expected for this adapter.
|
||||||
|
|
||||||
|
Standalone runtime command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd langbot-plugin-sdk
|
||||||
|
uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check
|
||||||
|
```
|
||||||
|
|
||||||
|
Probe plugin: `data/plugins/LangBot__EBAEventProbe` when live credentials are available.
|
||||||
|
|
||||||
|
Adapter live probe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -m py_compile tests/e2e/live_officialaccount_eba_probe.py
|
||||||
|
OFFICIALACCOUNT_TOKEN=... OFFICIALACCOUNT_ENCODING_AES_KEY=... OFFICIALACCOUNT_APP_SECRET=... OFFICIALACCOUNT_APP_ID=... uv run python tests/e2e/live_officialaccount_eba_probe.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Evidence JSONL path: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/officialaccount_eba_plugin_probe.jsonl` for plugin E2E, or `data/temp/officialaccount_eba_probe.jsonl` for direct adapter live probe.
|
||||||
|
|
||||||
|
Destructive operations: none.
|
||||||
|
|
||||||
|
Blocked items:
|
||||||
|
|
||||||
|
- `plugin-e2e-outbound`: proactive `send_message` is not supported for this adapter; Official Account responses must be produced through the inbound webhook reply window.
|
||||||
|
- Inbound image and voice live UI evidence remains pending; webhook conversion is covered by unit tests.
|
||||||
114
docs/event-based-agents/adapters/qqofficial.md
Normal file
114
docs/event-based-agents/adapters/qqofficial.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# QQOfficial EBA Adapter
|
||||||
|
|
||||||
|
Adapter directory: `src/langbot/pkg/platform/adapters/qqofficial/`
|
||||||
|
|
||||||
|
Manifest name: `qqofficial-eba`
|
||||||
|
|
||||||
|
Status: partial migration. The EBA adapter structure, manifest, converters, cache-backed safe APIs, platform API map, unit tests, and direct live probe scaffold are in place. A real QQ Official WebSocket bot on `dev.rockchin.top` received an inbound user message and drove LangBot into the normal pipeline path; the response path was blocked by the test environment model service returning `model_not_found` for `deepseek-v3`.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `appid` | yes | QQ Official app ID. |
|
||||||
|
| `secret` | yes | QQ Official app secret. |
|
||||||
|
| `token` | yes | QQ Official callback token. |
|
||||||
|
| `enable-webhook` | yes | Uses LangBot unified webhook when true; otherwise uses the QQ WebSocket gateway. |
|
||||||
|
| `enable-stream-reply` | yes | Enables C2C streaming replies when supported by the QQ Official endpoint. |
|
||||||
|
| `webhook_url` | no | Generated by LangBot and copied into the QQ Official callback settings in webhook mode. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
| Event | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `message.received` | adapter-live, unit | `C2C_MESSAGE_CREATE`, `DIRECT_MESSAGE_CREATE`, `GROUP_AT_MESSAGE_CREATE`, and `AT_MESSAGE_CREATE` map to common `MessageReceivedEvent`. A real WebSocket-mode QQ Official bot reached the LangBot pipeline on `dev.rockchin.top`; plugin JSONL evidence remains pending. |
|
||||||
|
| `platform.specific` | unit, blocked | Unmapped gateway events are emitted as structured `PlatformSpecificEvent`; live evidence is pending. |
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `send_message` | unit, blocked | Sends private C2C, group, and text-only channel messages through the existing QQ Official client. Live outbound UI verification is pending because the test pipeline failed before producing a bot response. |
|
||||||
|
| `reply_message` | unit, blocked | Replies using the source `QQOfficialEvent` message ID when available. Live reply was blocked by the test environment model service returning `model_not_found`. |
|
||||||
|
| `get_message` | unit | Returns cached inbound `MessageReceivedEvent`. |
|
||||||
|
| `get_user_info` | unit | Returns cached inbound sender. |
|
||||||
|
| `get_friend_list` | unit | Returns cached private senders. |
|
||||||
|
| `get_group_info` | unit | Returns cached group/channel metadata from inbound events. |
|
||||||
|
| `get_group_member_info` | unit | Returns cached group sender as a common member. |
|
||||||
|
| `get_group_member_list` | unit | Returns cached group members observed by the adapter. |
|
||||||
|
| `call_platform_api` | unit, blocked | Safe diagnostic actions are implemented; live calls are pending credentials. |
|
||||||
|
|
||||||
|
## Platform APIs
|
||||||
|
|
||||||
|
| Action | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `check_access_token` | unit, blocked | Calls the existing client token check. |
|
||||||
|
| `refresh_access_token` | unit, blocked | Forces token refresh. |
|
||||||
|
| `get_gateway_url` | unit, blocked | Fetches the WebSocket gateway URL. |
|
||||||
|
| `get_mode` | unit | Returns webhook and stream-reply mode. |
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Receive Component | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Source` | unit | Uses QQ message/event IDs and timestamp. |
|
||||||
|
| `Plain` | unit | Preserves text content. |
|
||||||
|
| `At` | unit | Group and channel mention events insert an adapter bot mention marker. |
|
||||||
|
| `Image` | unit | QQ image attachment URL is converted to common `Image`; falls back to URL if download fails. |
|
||||||
|
| `Unknown` | unit | Unsupported/empty native payloads become `Unknown`. |
|
||||||
|
| `Voice`, `File`, `Quote`, `Face`, `Forward`, mixed chain | blocked | Current native parser only exposes text and image attachments; live endpoint behavior still needs verification. |
|
||||||
|
|
||||||
|
| Send Component | Evidence | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Plain` | unit, blocked | Sends through private, group, or channel text APIs. |
|
||||||
|
| `At`, `AtAll` | unit, blocked | Converted to readable mention text. |
|
||||||
|
| `Image` | unit, blocked | Sends through the QQ Official rich media upload/send path for C2C and group targets. |
|
||||||
|
| `Voice` | unit, blocked | Sends through the QQ Official rich media upload/send path for C2C and group targets. |
|
||||||
|
| `File` | unit, blocked | Sends through the QQ Official rich media upload/send path for C2C and group targets. |
|
||||||
|
| `Quote`, `Forward`, mixed chain | unit, blocked | Flattened to ordered send payloads where possible. |
|
||||||
|
| `Face` | not-supported | No common QQ Official face mapping is implemented. |
|
||||||
|
|
||||||
|
## Verification Record
|
||||||
|
|
||||||
|
Test date: 2026-06-02
|
||||||
|
|
||||||
|
Endpoint/simulator: `dev.rockchin.top` with a real QQ Official WebSocket bot (`qqofficial-eba`, bot UUID `80a5560b-52b1-40e7-b7d6-4a2341eb4780`) and LangBot running from `/home/wgc/LangBotxg/LangBotEbaTest`.
|
||||||
|
|
||||||
|
Observed evidence:
|
||||||
|
|
||||||
|
- The QQ Official WebSocket bot was enabled with `enable-webhook=false`.
|
||||||
|
- A real user message reached LangBot and entered the standard pipeline path.
|
||||||
|
- The response path stopped at the model layer with `model_not_found` for `deepseek-v3`; this is a model/provider configuration issue, not an adapter conversion failure.
|
||||||
|
- `qq-webhook.langbot.dev` was temporarily routed through Caddy to `127.0.0.1:5301` for webhook checks, but the observed EBA bot used WebSocket mode.
|
||||||
|
|
||||||
|
Standalone runtime command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd langbot-plugin-sdk
|
||||||
|
uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check
|
||||||
|
```
|
||||||
|
|
||||||
|
Probe plugin: `data/plugins/LangBot__EBAEventProbe` when live credentials are available.
|
||||||
|
|
||||||
|
Adapter live probe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -m py_compile tests/e2e/live_qqofficial_eba_probe.py
|
||||||
|
QQOFFICIAL_APPID=... QQOFFICIAL_SECRET=... QQOFFICIAL_TOKEN=... uv run python tests/e2e/live_qqofficial_eba_probe.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Webhook-mode probe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QQOFFICIAL_APPID=... QQOFFICIAL_SECRET=... QQOFFICIAL_TOKEN=... uv run python tests/e2e/live_qqofficial_eba_probe.py --webhook --host 0.0.0.0 --port 5312
|
||||||
|
```
|
||||||
|
|
||||||
|
Evidence JSONL path: `data/temp/qqofficial_eba_probe.jsonl` for direct adapter live probe; plugin E2E evidence should use `data/temp/qqofficial_eba_plugin_probe.jsonl`.
|
||||||
|
|
||||||
|
Destructive operations: none implemented.
|
||||||
|
|
||||||
|
Blocked items:
|
||||||
|
|
||||||
|
- `plugin-e2e-ui`: standalone probe plugin JSONL evidence is still pending; the observed live run reached LangBot core/pipeline but was not recorded by the EBA probe plugin.
|
||||||
|
- `plugin-e2e-outbound`: waiting for visible QQ client verification of plugin `send_message`/`reply_message` output after a working model/provider is configured.
|
||||||
|
- Inbound non-text media and platform lifecycle events require endpoint evidence before they can be marked complete.
|
||||||
84
docs/event-based-agents/adapters/slack.md
Normal file
84
docs/event-based-agents/adapters/slack.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Slack EBA Adapter
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
Slack is migrated into `src/langbot/pkg/platform/adapters/slack/` with the standard EBA adapter layout:
|
||||||
|
|
||||||
|
- `adapter.py` owns lifecycle, listener dispatch, unified webhook handling, outbound send/reply, and event caches.
|
||||||
|
- `event_converter.py` maps Slack `im` and `app_mention` channel events to `message.received`.
|
||||||
|
- `message_converter.py` maps common `MessageChain` components to Slack text fallback and maps inbound Slack text/image payloads back to EBA components.
|
||||||
|
- `api_impl.py` provides cache-backed common read APIs.
|
||||||
|
- `platform_api.py` declares safe Slack-specific API actions.
|
||||||
|
- `manifest.yaml` declares `slack-eba`.
|
||||||
|
|
||||||
|
The legacy `src/langbot/pkg/platform/sources/slack.py` adapter is kept unchanged.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
|-------|----------|-------|
|
||||||
|
| `webhook_url` | No | Generated by LangBot. Paste it into Slack Event Subscriptions. |
|
||||||
|
| `bot_token` | Yes | Slack bot token, usually `xoxb-...`. |
|
||||||
|
| `signing_secret` | Yes | Slack app signing secret. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
| Event | Notes |
|
||||||
|
|-------|-------|
|
||||||
|
| `message.received` | Emitted for private `im` messages and channel `app_mention` events. Channel messages are mapped to group chats. |
|
||||||
|
| `platform.specific` | Reserved for Slack event types that are not converted into common message events. |
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
Required:
|
||||||
|
|
||||||
|
- `send_message`
|
||||||
|
- `reply_message`
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `get_message`
|
||||||
|
- `get_user_info`
|
||||||
|
- `get_friend_list`
|
||||||
|
- `get_group_info`
|
||||||
|
- `get_group_list`
|
||||||
|
- `get_group_member_list`
|
||||||
|
- `get_group_member_info`
|
||||||
|
- `call_platform_api`
|
||||||
|
|
||||||
|
Cache-backed APIs are only available after the relevant inbound event has been observed.
|
||||||
|
|
||||||
|
## Platform APIs
|
||||||
|
|
||||||
|
| Action | Notes |
|
||||||
|
|--------|-------|
|
||||||
|
| `get_mode` | Returns webhook mode and configured bot account id. |
|
||||||
|
| `auth_test` | Calls Slack `auth.test` with the configured bot token. |
|
||||||
|
|
||||||
|
## Known Limits
|
||||||
|
|
||||||
|
- Slack file/image outbound is currently represented as text fallback because the existing Slack SDK wrapper only exposes `chat_postMessage`.
|
||||||
|
- Inbound channel coverage follows the legacy adapter behavior: only `app_mention` events are treated as group messages.
|
||||||
|
- Real live testing requires a public callback URL configured in Slack Event Subscriptions.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Local mocked unit coverage validates manifest parity, event conversion, legacy listener compatibility, cache-backed APIs, send/reply routing, and declared platform APIs.
|
||||||
|
|
||||||
|
Plugin E2E evidence was captured on June 2, 2026 against `dev.rockchin.top` with Slack private DM input and `EBAEventProbe` through the standalone runtime.
|
||||||
|
|
||||||
|
Evidence file: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/slack_eba_plugin_probe.jsonl`.
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
- Real Slack private text produced `MessageReceived` with `adapter_name=slack-eba`, `Source + Plain`, private chat type, and filled `bot_uuid`.
|
||||||
|
- Safe common APIs passed: `get_message`, `get_user_info`, `get_friend_list`.
|
||||||
|
- Outbound component fallback sweep passed through `send_message`: plain/at/face, image, quote, file, and forward.
|
||||||
|
- Declared Slack platform APIs passed: `get_mode`, `auth_test`.
|
||||||
|
|
||||||
|
Still pending:
|
||||||
|
|
||||||
|
- Channel `app_mention` plugin E2E.
|
||||||
|
- Real inbound Slack file/image UI evidence.
|
||||||
|
|
||||||
|
Live probe scaffold: `tests/e2e/live_slack_eba_probe.py`.
|
||||||
139
docs/event-based-agents/adapters/telegram.md
Normal file
139
docs/event-based-agents/adapters/telegram.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Telegram EBA Adapter
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Telegram has been migrated to the EBA adapter directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/langbot/pkg/platform/adapters/telegram/
|
||||||
|
├── adapter.py
|
||||||
|
├── api_impl.py
|
||||||
|
├── event_converter.py
|
||||||
|
├── manifest.yaml
|
||||||
|
├── message_converter.py
|
||||||
|
├── platform_api.py
|
||||||
|
└── types.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter is registered as `telegram-eba`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Default | Description |
|
||||||
|
|-------|----------|---------|-------------|
|
||||||
|
| `token` | Yes | `""` | Telegram Bot API token from BotFather. |
|
||||||
|
| `markdown_card` | No | `true` | Whether to render Markdown card style replies. |
|
||||||
|
| `enable-stream-reply` | Yes | `false` | Whether to use Telegram streaming reply mode. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
Telegram declares these EBA events:
|
||||||
|
|
||||||
|
- `message.received`
|
||||||
|
- `message.edited`
|
||||||
|
- `message.reaction`
|
||||||
|
- `group.member_joined`
|
||||||
|
- `group.member_left`
|
||||||
|
- `group.member_banned`
|
||||||
|
- `bot.invited_to_group`
|
||||||
|
- `bot.removed_from_group`
|
||||||
|
- `bot.muted`
|
||||||
|
- `bot.unmuted`
|
||||||
|
- `platform.specific`
|
||||||
|
|
||||||
|
`platform.specific` is currently used for Telegram-only callback and chat-member update payloads that do not yet have a more specific common event type.
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Status | Notes |
|
||||||
|
|-----|--------|-------|
|
||||||
|
| `send_message` | Supported | Supports text, image, file, and mixed message chains. |
|
||||||
|
| `reply_message` | Supported | Supports quoted replies through the original message event. |
|
||||||
|
| `edit_message` | Supported | Uses Telegram message editing APIs. |
|
||||||
|
| `delete_message` | Supported | Deletes messages where bot permissions allow it. |
|
||||||
|
| `forward_message` | Supported | Forwards a message between Telegram chats. |
|
||||||
|
| `get_group_info` | Supported | Uses Telegram chat metadata. |
|
||||||
|
| `get_group_member_list` | Supported | Telegram only exposes administrators through the Bot API; this returns the available member set. |
|
||||||
|
| `get_group_member_info` | Supported | Maps Telegram member status to EBA member roles. |
|
||||||
|
| `get_user_info` | Supported | Uses Telegram `get_chat` for user chat metadata. |
|
||||||
|
| `upload_file` | Not supported | Telegram has no standalone upload endpoint; files are uploaded as part of messages. The adapter raises `NotSupportedError`. |
|
||||||
|
| `get_file_url` | Supported | Returns the Bot API file URL. Test output redacts the bot token. |
|
||||||
|
| `mute_member` | Supported | Requires a supergroup and bot moderation permission. |
|
||||||
|
| `unmute_member` | Supported | Uses current `telegram.ChatPermissions` fields. |
|
||||||
|
| `kick_member` | Supported | Destructive; should only be run against disposable users/bots in tests. |
|
||||||
|
| `leave_group` | Supported | Destructive; should run at the end of a live test. |
|
||||||
|
| `call_platform_api` | Supported | See below. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
`call_platform_api(action, params)` supports:
|
||||||
|
|
||||||
|
- `pin_message`
|
||||||
|
- `unpin_message`
|
||||||
|
- `unpin_all_messages`
|
||||||
|
- `get_chat_administrators`
|
||||||
|
- `set_chat_title`
|
||||||
|
- `set_chat_description`
|
||||||
|
- `get_chat_member_count`
|
||||||
|
- `send_chat_action`
|
||||||
|
- `create_chat_invite_link`
|
||||||
|
- `answer_callback_query`
|
||||||
|
|
||||||
|
## Live Test Record
|
||||||
|
|
||||||
|
The live probe is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python tests/e2e/live_telegram_eba_probe.py --help
|
||||||
|
```
|
||||||
|
|
||||||
|
It supports private chat tests, group/supergroup tests, moderation tests, destructive tests, and a callback-only mode.
|
||||||
|
|
||||||
|
Verified on May 7, 2026:
|
||||||
|
|
||||||
|
- Private chat message APIs: send, reply, edit, delete, forward.
|
||||||
|
- Private chat media APIs: image/file sending and `get_file_url`.
|
||||||
|
- User API: `get_user_info`.
|
||||||
|
- Supergroup APIs: group info, member list, member info, administrators, member count, invite link.
|
||||||
|
- Supergroup mutation APIs: pin, unpin, unpin all, set title, restore title, set description, restore description.
|
||||||
|
- Moderation APIs: mute and unmute against a non-owner target bot.
|
||||||
|
- Destructive APIs: kick a disposable target bot, then make the test bot leave the test group.
|
||||||
|
- Event conversion observed for `message.received`, `group.member_banned`, `group.member_left`, `bot.removed_from_group`, and Telegram-specific chat-member updates.
|
||||||
|
|
||||||
|
The test fixed one real compatibility issue: `unmute_member` previously used Telegram's removed `can_send_media_messages` permission field. It now uses the split media permission fields required by current `python-telegram-bot`.
|
||||||
|
|
||||||
|
## Standalone Runtime Plugin E2E Record
|
||||||
|
|
||||||
|
Verified on May 10, 2026 with `EBAEventProbe`, SDK standalone runtime, Telegram Lite, `@rockchinq_bot`, and `Rock'sBotGroup`.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- Private chat JSONL: `data/temp/telegram-plugin-e2e-rerun.jsonl`
|
||||||
|
- Group chat JSONL: `data/temp/telegram-plugin-e2e-group.jsonl`
|
||||||
|
- Private media JSONL: `data/temp/telegram-plugin-e2e-media-ui.jsonl`
|
||||||
|
|
||||||
|
Observed and verified:
|
||||||
|
|
||||||
|
- `MessageReceived` reached the plugin with `bot_uuid=eba-telegram-live`, `adapter_name=telegram`, common sender/chat fields, and common `MessageChain` content.
|
||||||
|
- `BotInvitedToGroup` reached the plugin after adding the bot to `Rock'sBotGroup`.
|
||||||
|
- SDK API calls succeeded: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage, workspace storage, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`.
|
||||||
|
- Outbound component sweep succeeded in private and group chats: plain text, mention text/equivalent, base64 image, quoted reply, file/document, and flattened forward fallback. Group mode also covered `AtAll` fallback behavior.
|
||||||
|
- Real Telegram Lite private-chat inbound media was verified through the plugin path: a sent document arrived as common `File`, and a sent photo arrived as common `Image`.
|
||||||
|
- Telegram platform API sweep succeeded for safe group actions: `get_chat_administrators`, `get_chat_member_count`, and `send_chat_action`.
|
||||||
|
- Common group/user APIs succeeded in group mode: `get_user_info`, `get_group_info`, `get_group_member_list`, and `get_group_member_info`.
|
||||||
|
|
||||||
|
Documented limits in this E2E run:
|
||||||
|
|
||||||
|
- Real Telegram UI inbound voice, sticker/emoji-as-common-component, and reply/quote messages were not completed in the plugin E2E evidence.
|
||||||
|
- `get_message`, `get_friend_list`, and `get_group_list` are not supported by this Telegram adapter.
|
||||||
|
- Mutating/destructive Telegram-specific actions such as pin/unpin, title/description changes, invite-link creation, moderation, kick, and leave were not repeated in the plugin run. They remain opt-in live-probe cases.
|
||||||
|
- Telegram does not expose a portable common `Face` component for native sticker/emoji semantics in the current adapter.
|
||||||
|
|
||||||
|
## Notes for Future Adapters
|
||||||
|
|
||||||
|
Telegram is the reference implementation for:
|
||||||
|
|
||||||
|
- Keeping platform-specific actions behind `call_platform_api`.
|
||||||
|
- Treating unsupported common APIs as explicit `NotSupportedError`.
|
||||||
|
- Marking destructive live test operations behind CLI flags.
|
||||||
|
- Redacting access tokens from live probe output.
|
||||||
130
docs/event-based-agents/adapters/wecom.md
Normal file
130
docs/event-based-agents/adapters/wecom.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# WeCom EBA Adapter
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
WeCom application messages now have an EBA adapter directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/langbot/pkg/platform/adapters/wecom/
|
||||||
|
├── adapter.py
|
||||||
|
├── api_impl.py
|
||||||
|
├── event_converter.py
|
||||||
|
├── manifest.yaml
|
||||||
|
├── message_converter.py
|
||||||
|
├── platform_api.py
|
||||||
|
└── types.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter is registered as `wecom-eba`.
|
||||||
|
|
||||||
|
This record covers the regular WeCom application-message adapter. WeCom AI Bot (`wecombot-eba`) uses a different protocol flow and is documented separately in `wecombot.md`. WeCom Customer Service (`wecomcs`) remains a separate follow-up migration.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Default | Description |
|
||||||
|
|-------|----------|---------|-------------|
|
||||||
|
| `webhook_url` | No | `""` | Unified webhook URL copied into the WeCom application callback settings. |
|
||||||
|
| `corpid` | Yes | `""` | WeCom corporate ID. |
|
||||||
|
| `secret` | Yes | `""` | WeCom application secret. |
|
||||||
|
| `token` | Yes | `""` | WeCom callback token. |
|
||||||
|
| `EncodingAESKey` | Yes | `""` | WeCom callback encryption key. |
|
||||||
|
| `contacts_secret` | No | `""` | Contacts secret for contact-list based helper APIs. |
|
||||||
|
| `api_base_url` | No | `https://qyapi.weixin.qq.com/cgi-bin` | WeCom API base URL, overrideable for proxy/private-network deployments. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
WeCom declares these EBA events:
|
||||||
|
|
||||||
|
- `message.received`
|
||||||
|
- `platform.specific`
|
||||||
|
|
||||||
|
`message.received` currently covers text and image application callbacks. Other WeCom callback types are surfaced as `platform.specific` so plugins can inspect the raw structured payload without crashing the common message path.
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Status | Notes |
|
||||||
|
|-----|--------|-------|
|
||||||
|
| `send_message` | Supported | Private/person target only. `target_id` must be `user_id|agent_id`. Supports text, image, voice, file, flattened forward, and quote fallback. |
|
||||||
|
| `reply_message` | Supported | Replies to the original WeCom sender and application agent from `source_platform_object`. |
|
||||||
|
| `get_message` | Supported from cache | Returns cached inbound `MessageReceivedEvent` by message ID. |
|
||||||
|
| `get_user_info` | Supported | Uses cached event users first, then WeCom `user/get`. |
|
||||||
|
| `get_friend_list` | Partial | Returns users seen by this adapter instance. Full contacts listing is not declared as common coverage. |
|
||||||
|
| `call_platform_api` | Supported | See below. |
|
||||||
|
| `edit_message` | Not supported | WeCom application messages do not expose a general edit endpoint for sent messages. |
|
||||||
|
| `delete_message` | Not supported | WeCom application messages do not expose a general delete endpoint for sent messages. |
|
||||||
|
| `get_group_info` / member APIs | Not supported | Regular WeCom application callbacks handled here are private user messages, not group-chat bot messages. |
|
||||||
|
| `upload_file` / `get_file_url` | Not supported as common APIs | WeCom media upload is used internally while sending image/voice/file components; no portable standalone common file URL is exposed. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
`call_platform_api(action, params)` supports:
|
||||||
|
|
||||||
|
- `check_access_token`
|
||||||
|
- `refresh_access_token`
|
||||||
|
- `get_user_info`
|
||||||
|
- `send_to_all`
|
||||||
|
|
||||||
|
`send_to_all` requires a configured `contacts_secret` with suitable contact visibility and should be treated as a broad-send operation in live testing.
|
||||||
|
|
||||||
|
## Unit Verification
|
||||||
|
|
||||||
|
Covered by:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/unit_tests/platform/test_wecom_eba_adapter.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The unit tests cover:
|
||||||
|
|
||||||
|
- Manifest events/APIs/platform actions match adapter declarations.
|
||||||
|
- Outbound component conversion for text, image, voice, file, quote fallback, and byte-safe text splitting.
|
||||||
|
- Text callback conversion to `MessageReceivedEvent`.
|
||||||
|
- Legacy `FriendMessage` compatibility.
|
||||||
|
- EBA listener dispatch and inbound message/user cache.
|
||||||
|
- `send_message`, `reply_message`, and safe platform API dispatch against a mocked WeCom client.
|
||||||
|
|
||||||
|
## Standalone Runtime Plugin E2E Record
|
||||||
|
|
||||||
|
Verified on May 27, 2026 with `EBAEventProbe`, SDK standalone runtime, LangBot core, and a real WeCom desktop client against the server test environment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd langbot-plugin-sdk
|
||||||
|
uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check
|
||||||
|
|
||||||
|
cd LangBot
|
||||||
|
uv run main.py --standalone-runtime
|
||||||
|
|
||||||
|
cd data/plugins/LangBot__EBAEventProbe
|
||||||
|
EBA_PROBE_API=1 EBA_PROBE_COMPONENT_SWEEP=1 EBA_PROBE_PLATFORM_API=1 \
|
||||||
|
uv --project /absolute/path/to/langbot-plugin-sdk run python -m langbot_plugin.cli.__init__ run
|
||||||
|
```
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- JSONL: `data/temp/wecom_eba_plugin_probe.jsonl`
|
||||||
|
- Bot: `wecom-eba`
|
||||||
|
- Client: real WeCom desktop client
|
||||||
|
- Environment: `dev.rockchin.top` test server
|
||||||
|
|
||||||
|
Observed and verified:
|
||||||
|
|
||||||
|
- A real private WeCom user message reached the plugin as `MessageReceived` with `adapter_name=wecom-eba`, common sender/chat fields, and `Source + Plain`.
|
||||||
|
- SDK API calls succeeded through the standalone runtime, including `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin/workspace storage, and manifest/list APIs.
|
||||||
|
- Safe adapter API checks succeeded through the plugin path for cached message/user data and declared safe platform API actions.
|
||||||
|
|
||||||
|
Still required for stricter acceptance:
|
||||||
|
|
||||||
|
- Send a private image and confirm common `Image` reaches the plugin.
|
||||||
|
- Have the plugin call `send_message` and `reply_message` for text and one media component, then verify the WeCom client receives the bot output.
|
||||||
|
- Exercise `send_to_all` only with a disposable visible-contact scope.
|
||||||
|
- Trigger one non-text/image callback, if available, and confirm it becomes `PlatformSpecificEventReceived`.
|
||||||
|
|
||||||
|
## Current Acceptance
|
||||||
|
|
||||||
|
Current status is **partial EBA acceptance**.
|
||||||
|
|
||||||
|
Blocked items:
|
||||||
|
|
||||||
|
- Real inbound image/voice/file evidence was not completed in this run.
|
||||||
|
- Inbound voice/file callback parsing is not present in the legacy `WecomClient.get_message()` path, so the EBA adapter does not claim those receive components yet.
|
||||||
|
- Group/member/moderation APIs do not apply to this regular WeCom application-message adapter.
|
||||||
148
docs/event-based-agents/adapters/wecombot.md
Normal file
148
docs/event-based-agents/adapters/wecombot.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# WeComBot EBA Adapter
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
WeCom AI Bot now has an EBA adapter directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/langbot/pkg/platform/adapters/wecombot/
|
||||||
|
├── adapter.py
|
||||||
|
├── api_impl.py
|
||||||
|
├── event_converter.py
|
||||||
|
├── manifest.yaml
|
||||||
|
├── message_converter.py
|
||||||
|
├── platform_api.py
|
||||||
|
└── types.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter is registered as `wecombot-eba`.
|
||||||
|
|
||||||
|
This is separate from regular WeCom internal applications (`wecom-eba`). WeComBot supports WebSocket long connection mode, which does not require a webhook URL. Webhook mode remains available when `enable-webhook=true`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Default | Description |
|
||||||
|
|-------|----------|---------|-------------|
|
||||||
|
| `BotId` | Yes for WebSocket mode | `""` | WeCom AI Bot ID. |
|
||||||
|
| `robot_name` | Yes | `""` | Bot display name used to strip bot mentions from incoming group text. |
|
||||||
|
| `enable-webhook` | Yes | `false` | `false` uses WebSocket long connection mode; `true` uses webhook callback mode. |
|
||||||
|
| `webhook_url` | No | `""` | Unified webhook URL, only needed when webhook mode is enabled. |
|
||||||
|
| `Secret` | Yes for WebSocket mode | `""` | WeCom AI Bot secret for long connection mode. |
|
||||||
|
| `Corpid` | Yes for webhook mode | `""` | WeCom corporate ID for webhook callback mode. |
|
||||||
|
| `Token` | Yes for webhook mode | `""` | WeCom callback token. |
|
||||||
|
| `EncodingAESKey` | Yes for webhook mode; optional for WebSocket media decrypt | `""` | Message encryption/decryption key. |
|
||||||
|
| `enable-stream-reply` | No | `true` | Enables WeComBot streaming replies. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
WeComBot declares these EBA events:
|
||||||
|
|
||||||
|
- `message.received`
|
||||||
|
- `feedback.received`
|
||||||
|
- `platform.specific`
|
||||||
|
|
||||||
|
`message.received` covers private and group messages from the WeComBot SDK. `feedback.received` covers WeComBot like/dislike feedback callbacks. Native SDK events without a common EBA equivalent are emitted as `platform.specific`.
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Status | Notes |
|
||||||
|
|-----|--------|-------|
|
||||||
|
| `send_message` | Supported in WebSocket mode | Sends proactive markdown/text to a person or group chat ID. Webhook mode raises `NotSupportedError` because the platform callback flow has no proactive send path here. |
|
||||||
|
| `reply_message` | Supported | Replies through native `req_id` in WebSocket mode or stream finalization/cache in webhook mode. |
|
||||||
|
| `get_message` | Supported from cache | Returns cached inbound `MessageReceivedEvent` by message ID. |
|
||||||
|
| `get_user_info` | Supported from cache | WeComBot events carry user info; no full user lookup endpoint is declared. |
|
||||||
|
| `get_friend_list` | Partial | Returns users observed by this adapter instance. |
|
||||||
|
| `get_group_info` | Supported from cache | Returns groups observed from inbound group messages. |
|
||||||
|
| `get_group_member_info` | Supported from cache | Returns observed sender/group-member pairs. |
|
||||||
|
| `get_group_member_list` | Partial | Returns observed members for the cached group only. |
|
||||||
|
| `call_platform_api` | Supported | See below. |
|
||||||
|
| `edit_message` / `delete_message` / `forward_message` | Not supported | WeComBot does not expose portable common APIs for these operations in the current SDK wrapper. |
|
||||||
|
| `upload_file` / `get_file_url` | Not supported as common APIs | Media is represented inside messages; no portable standalone file upload/URL API is declared. |
|
||||||
|
| moderation / leave APIs | Not supported | WeComBot does not expose equivalent common moderation operations through this adapter. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
`call_platform_api(action, params)` supports:
|
||||||
|
|
||||||
|
- `is_websocket_mode`
|
||||||
|
- `get_stream_session_status`
|
||||||
|
- `send_markdown`
|
||||||
|
|
||||||
|
`send_markdown` is only available in WebSocket mode.
|
||||||
|
|
||||||
|
## Unit Verification
|
||||||
|
|
||||||
|
Covered by:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=/Users/wangqiang/code/python/langbot-plugin-sdk/src uv run pytest tests/unit_tests/platform/test_wecombot_eba_adapter.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The unit tests cover:
|
||||||
|
|
||||||
|
- Manifest events/APIs/platform actions match adapter declarations.
|
||||||
|
- Outbound common components flatten to WeComBot markdown/text.
|
||||||
|
- Private and group native events become `MessageReceivedEvent`.
|
||||||
|
- Inbound image, file, voice, and quote components map to common `MessageChain`.
|
||||||
|
- Legacy `FriendMessage`/`GroupMessage` compatibility.
|
||||||
|
- EBA listener dispatch, message/user/group/member cache, reply, send, streaming chunk, feedback, and platform API calls.
|
||||||
|
|
||||||
|
## Live Probe
|
||||||
|
|
||||||
|
The direct adapter probe is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=/absolute/path/to/langbot-plugin-sdk/src uv run python tests/e2e/live_wecombot_eba_probe.py --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Default mode is WebSocket long connection and requires:
|
||||||
|
|
||||||
|
- `WECOMBOT_BOT_ID`
|
||||||
|
- `WECOMBOT_SECRET`
|
||||||
|
- `WECOMBOT_ROBOT_NAME`
|
||||||
|
- optional `WECOMBOT_ENCODING_AES_KEY`
|
||||||
|
|
||||||
|
Webhook mode uses `--webhook` and requires:
|
||||||
|
|
||||||
|
- `WECOMBOT_TOKEN`
|
||||||
|
- `WECOMBOT_ENCODING_AES_KEY`
|
||||||
|
- `WECOMBOT_CORPID`
|
||||||
|
|
||||||
|
The probe writes JSONL evidence to `data/temp/wecombot_eba_live_probe.jsonl`, waits for a real WeComBot message, records common EBA event fields and message components, then runs safe cached/common/platform API checks.
|
||||||
|
|
||||||
|
## Standalone Runtime Plugin E2E Record
|
||||||
|
|
||||||
|
Verified on May 27, 2026 with `EBAEventProbe`, SDK standalone runtime, LangBot core, and the real WeCom desktop client in a WeCom AI Bot private chat.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- JSONL: `data/temp/wecombot_eba_plugin_probe.jsonl`
|
||||||
|
- Bot UUID: `9f5d4125-7b6d-4c98-8ca2-111111111111`
|
||||||
|
- Adapter: `wecombot-eba`
|
||||||
|
- Client: real WeCom desktop client, private `LangBot` BOT chat
|
||||||
|
- Mode: WebSocket long connection (`enable-webhook=false`)
|
||||||
|
|
||||||
|
Observed and verified:
|
||||||
|
|
||||||
|
- A real user-side message reached the plugin as `MessageReceived` with `adapter_name=wecombot-eba`, common sender/chat fields, and `Source + Plain`.
|
||||||
|
- SDK API calls succeeded through the standalone runtime: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin/workspace storage, manifest/list APIs, and safe cached common platform APIs.
|
||||||
|
- Outbound component sweep was visible in the WeCom client and returned `errcode=0`: plain/mention/face fallback, base64 image marker, quote fallback, file marker, and flattened forward fallback.
|
||||||
|
- Declared WeComBot platform APIs succeeded through `plugin.call_platform_api`: `is_websocket_mode`, `get_stream_session_status`, and `send_markdown`.
|
||||||
|
- The `send_markdown` platform API produced visible bot output in the WeCom client.
|
||||||
|
|
||||||
|
Not completed:
|
||||||
|
|
||||||
|
- Clicking the visible WeCom AI feedback button did not produce a `FeedbackReceived` JSONL entry in this run, so `feedback.received` remains unverified at plugin E2E level.
|
||||||
|
- Group chat inbound and group cache/member coverage still need a real group-side trigger.
|
||||||
|
- Real inbound image/file/voice from the WeCom client was not exercised.
|
||||||
|
|
||||||
|
## Current Acceptance
|
||||||
|
|
||||||
|
Current status is **partial EBA acceptance**.
|
||||||
|
|
||||||
|
Blocked or limited items:
|
||||||
|
|
||||||
|
- `feedback.received` is implemented and unit-covered, but real plugin E2E feedback evidence was not observed from the desktop client click.
|
||||||
|
- Outbound image/voice/file are flattened as textual markers because the WeComBot SDK reply/proactive path used here is markdown/text oriented.
|
||||||
|
- Group member APIs are cache-backed and only know members observed in received messages.
|
||||||
|
- Destructive or moderation APIs are not declared because the current WeComBot protocol surface does not provide safe common equivalents.
|
||||||
161
docs/event-based-agents/adapters/wecomcs.md
Normal file
161
docs/event-based-agents/adapters/wecomcs.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# WeCom Customer Service EBA Adapter
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
WeCom Customer Service now has an EBA adapter directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/langbot/pkg/platform/adapters/wecomcs/
|
||||||
|
├── adapter.py
|
||||||
|
├── api_impl.py
|
||||||
|
├── event_converter.py
|
||||||
|
├── manifest.yaml
|
||||||
|
├── message_converter.py
|
||||||
|
├── platform_api.py
|
||||||
|
└── types.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter is registered as `wecomcs-eba`. It is separate from regular WeCom application messages (`wecom-eba`) and WeCom AI Bot (`wecombot-eba`).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Required | Default | Description |
|
||||||
|
|-------|----------|---------|-------------|
|
||||||
|
| `webhook_url` | No | `""` | Unified webhook URL copied into the WeCom Customer Service callback settings. |
|
||||||
|
| `corpid` | Yes | `""` | WeCom corporate ID. |
|
||||||
|
| `secret` | Yes | `""` | Customer Service secret used for access tokens. |
|
||||||
|
| `token` | Yes | `""` | Customer Service callback token. |
|
||||||
|
| `EncodingAESKey` | Yes | `""` | Customer Service callback encryption key. |
|
||||||
|
| `api_base_url` | No | `https://qyapi.weixin.qq.com/cgi-bin` | WeCom API base URL, overrideable for proxy/private-network deployments. |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
| Event | Status | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `message.received` | Plugin E2E UI covered for text | Text, image, file, and voice payloads convert to common EBA message components in unit tests. Real WeChat customer-side UI text reached `EBAEventProbe` on May 27, 2026. |
|
||||||
|
| `platform.specific` | Unit covered | Non-message or unknown Customer Service payloads become structured `PlatformSpecificEvent` records. |
|
||||||
|
|
||||||
|
## Common APIs
|
||||||
|
|
||||||
|
| API | Status | Notes |
|
||||||
|
|-----|--------|-------|
|
||||||
|
| `send_message` | Plugin E2E outbound covered | Private/person target only. `target_id` must be `external_userid|open_kfid`. Text and image are implemented; voice/file are explicitly unsupported. |
|
||||||
|
| `reply_message` | Plugin E2E partial | Replies through Customer Service `kf/send_msg` using the original `source_platform_object`. The pipeline reply path reached the send API, but the dev account later hit WeCom `95001 send msg count limit`. |
|
||||||
|
| `get_message` | Plugin E2E covered from cache | Returns cached inbound `MessageReceivedEvent` by message ID. |
|
||||||
|
| `get_user_info` | Plugin E2E covered | Uses cached event users first, then Customer Service `customer/batchget`. |
|
||||||
|
| `get_friend_list` | Plugin E2E covered, partial | Returns customer users seen by this adapter instance. |
|
||||||
|
| `call_platform_api` | Unit covered | See platform-specific APIs below. |
|
||||||
|
| `edit_message` / `delete_message` | Not supported | WeCom Customer Service does not expose a general edit/delete endpoint for bot-sent messages in this adapter. |
|
||||||
|
| Group/member/moderation APIs | Not supported | Customer Service conversations handled here are private customer sessions, not group chats. |
|
||||||
|
| `upload_file` / `get_file_url` | Not supported | Media upload is used internally for outbound image; no portable file URL common API is exposed. |
|
||||||
|
|
||||||
|
## Platform-Specific APIs
|
||||||
|
|
||||||
|
| Action | Status | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| `check_access_token` | Unit covered | Checks whether the current access token is present. |
|
||||||
|
| `refresh_access_token` | Unit covered | Refreshes the Customer Service access token. |
|
||||||
|
| `get_customer_info` | Unit covered | Calls Customer Service customer lookup by `external_userid`. |
|
||||||
|
|
||||||
|
## Message Components
|
||||||
|
|
||||||
|
Receive:
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| `Source` | Unit covered | Uses Customer Service `msgid` and `send_time`. |
|
||||||
|
| `Plain` | Unit covered | Text payload content is preserved. |
|
||||||
|
| `Image` | Unit covered | Uses the base64 data URL produced by the existing SDK image download path. |
|
||||||
|
| `Voice` | Unit covered | Maps exposed voice media ID to common `Voice.voice_id`; live UI evidence pending. |
|
||||||
|
| `File` | Unit covered | Maps exposed file media ID/name/size to common `File`; live UI evidence pending. |
|
||||||
|
| `Quote`, `At`, `AtAll`, `Face`, `Forward` | Not supported inbound | The current Customer Service SDK event model does not expose these as structured inbound fields. |
|
||||||
|
| `Unknown` | Unit covered | Unsupported message types become `Unknown` in message conversion or `platform.specific` at event level. |
|
||||||
|
|
||||||
|
Send:
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| `Plain` | Plugin E2E outbound covered | Sends through `kf/send_msg` text. |
|
||||||
|
| `Image` | Plugin E2E outbound covered | Uploads media as WeCom image media and sends through `kf/send_msg` image. |
|
||||||
|
| `Quote`, `At`, `AtAll`, `Forward` | Unit covered fallback, live partially blocked | Flattened to text where possible. In the May 27 sweep, later text sends hit WeCom `95001 send msg count limit` after the successful text/image sends. |
|
||||||
|
| `Voice`, `File`, `Face` | Not supported | The adapter raises `NotSupportedError`; no tested Customer Service send path is implemented. |
|
||||||
|
|
||||||
|
## Unit Verification
|
||||||
|
|
||||||
|
Covered by:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=/Users/wangqiang/code/python/langbot-plugin-sdk/src uv run pytest tests/unit_tests/platform/test_wecomcs_eba_adapter.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Result on May 27, 2026: `10 passed`.
|
||||||
|
|
||||||
|
The local `PYTHONPATH` is required in this workspace because the installed SDK package in the LangBot venv does not contain the newer `langbot_plugin.api.entities.builtin.platform.errors` module; the existing EBA adapter tests need the same SDK override.
|
||||||
|
|
||||||
|
## Live Probe
|
||||||
|
|
||||||
|
Auxiliary direct adapter probe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=/path/to/langbot-plugin-sdk/src uv run python -m py_compile tests/e2e/live_wecomcs_eba_probe.py
|
||||||
|
|
||||||
|
WECOMCS_CORPID=... \
|
||||||
|
WECOMCS_SECRET=... \
|
||||||
|
WECOMCS_TOKEN=... \
|
||||||
|
WECOMCS_ENCODING_AES_KEY=... \
|
||||||
|
PYTHONPATH=/path/to/langbot-plugin-sdk/src \
|
||||||
|
uv run python tests/e2e/live_wecomcs_eba_probe.py \
|
||||||
|
--path /wecomcs/callback \
|
||||||
|
--log data/temp/wecomcs_eba_live_probe.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
This probe is diagnostic only. Final EBA acceptance still requires the standalone SDK runtime plus `EBAEventProbe` plugin path.
|
||||||
|
|
||||||
|
## Standalone Runtime Plugin E2E Record
|
||||||
|
|
||||||
|
Completed partial plugin E2E on May 27, 2026 against `dev.rockchin.top` and the WeChat customer-side UI entry `微信 -> 客服消息 -> 浪波智能客服`.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- Server JSONL: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/wecomcs_eba_plugin_probe.jsonl`
|
||||||
|
- Trigger text: `EBA wecomcs dedupe probe 2026-05-27`
|
||||||
|
- `bot_uuid`: `cc810d2c-91f3-4f92-8f27-e1bf9f7b6cb4`
|
||||||
|
- `adapter_name`: `wecomcs-eba`
|
||||||
|
- Observed common event: `MessageReceived`, `event.type=message.received`
|
||||||
|
- Observed message chain: `Source + Plain`
|
||||||
|
- Observed chat: `chat_type=private`, `chat_id=external_userid|open_kfid`
|
||||||
|
- Observed sender: customer `User` with nickname/avatar from Customer Service lookup
|
||||||
|
- Plugin API probe: `send_message`, `get_message`, `get_user_info`, `get_friend_list`, plugin/workspace storage, and manifest/list APIs succeeded
|
||||||
|
- Component sweep: outbound `Plain` and `Image` succeeded; `Face` and `File` returned explicit `NotSupportedError`; later quote/forward fallback sends were blocked by WeCom `95001 send msg count limit`
|
||||||
|
|
||||||
|
Command shape used:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd langbot-plugin-sdk
|
||||||
|
uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check
|
||||||
|
|
||||||
|
cd LangBot
|
||||||
|
PYTHONPATH=/absolute/path/to/langbot-plugin-sdk/src uv run main.py --standalone-runtime
|
||||||
|
|
||||||
|
cd data/plugins/LangBot__EBAEventProbe
|
||||||
|
DEBUG_RUNTIME_WS_URL=ws://127.0.0.1:5401/plugin/ws \
|
||||||
|
EBA_PROBE_LOG=/absolute/path/to/LangBot/data/temp/wecomcs_eba_plugin_probe.jsonl \
|
||||||
|
EBA_PROBE_API=1 \
|
||||||
|
EBA_PROBE_COMPONENT_SWEEP=1 \
|
||||||
|
EBA_PROBE_PLATFORM_API=1 \
|
||||||
|
uv --project /absolute/path/to/langbot-plugin-sdk run python -m langbot_plugin.cli.__init__ run
|
||||||
|
```
|
||||||
|
|
||||||
|
Required real UI trigger: send a Customer Service message from the WeCom/WeChat customer-side UI to the configured `dev.rockchin.top` Customer Service account.
|
||||||
|
|
||||||
|
## Current Acceptance
|
||||||
|
|
||||||
|
Current status is **partial EBA acceptance**.
|
||||||
|
|
||||||
|
Blocked or pending items:
|
||||||
|
|
||||||
|
- Inbound UI media (`Image`, `Voice`, `File`) was not sent from the real WeChat customer UI during this run, so receive-side media remains unit-covered only.
|
||||||
|
- Pipeline auto-reply reached `kf/send_msg`, but the test account hit WeCom `95001 send msg count limit` after successful plugin outbound text/image sends. This is recorded as an account/platform rate-limit block, not a conversion or API-shape failure.
|
||||||
|
- The current `EBAEventProbe` run did not call the adapter-specific `call_platform_api` actions (`check_access_token`, `refresh_access_token`, `get_customer_info`); the platform API map remains unit-covered.
|
||||||
|
- Inbound voice/file depends on whether the real Customer Service callback plus `sync_msg` endpoint returns those fields in the shape the local SDK models.
|
||||||
|
- Group, member, edit, delete, moderation, and standalone file URL APIs are intentionally not declared because this Customer Service protocol path does not provide tested common equivalents.
|
||||||
595
docs/review/box-architecture.md
Normal file
595
docs/review/box-architecture.md
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
# Box 系统架构深度分析
|
||||||
|
|
||||||
|
> 更新日期: 2026-06-02
|
||||||
|
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
||||||
|
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
|
> 相关文档: [SaaS 阻塞项](./box-issues.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 全局架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ LangBot 主进程 │
|
||||||
|
│ │
|
||||||
|
│ LocalAgentRunner ──> ToolManager ──> NativeToolLoader │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ exec / read / write / edit │
|
||||||
|
│ │ │ glob / grep │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ├──> MCPLoader ──> BoxStdioSession │
|
||||||
|
│ │ │ (shared 容器, 多 process) │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ├──> SkillToolLoader (activate 工具) │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ├──> SkillAuthoringToolLoader │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ └──> PluginToolLoader │
|
||||||
|
│ │ │
|
||||||
|
│ BoxService (门面) │
|
||||||
|
│ ├─ Profile 管理 (locked 字段) │
|
||||||
|
│ ├─ Host mount 校验 (allowed_mount_roots) │
|
||||||
|
│ ├─ Workspace quota 检查 │
|
||||||
|
│ ├─ 输出截断 (head+tail) │
|
||||||
|
│ ├─ Session ID 模板解析 (resolve_box_session_id) │
|
||||||
|
│ ├─ 技能挂载组装 (build_skill_extra_mounts) │
|
||||||
|
│ ├─ 重连循环 (_reconnect_loop, 指数退避) │
|
||||||
|
│ └─ BoxRuntimeConnector │
|
||||||
|
│ ├─ 心跳 loop (20s ping) │
|
||||||
|
│ └─ ActionRPCBoxClient │
|
||||||
|
│ │ Action RPC (stdio 或 WebSocket) │
|
||||||
|
│ │
|
||||||
|
│ SkillManager (skill_mgr) │
|
||||||
|
│ └─ 从 Box runtime 拉取 skills, 不可用时回落 data/skills │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Box Runtime 进程 (SDK 侧) │
|
||||||
|
│ │
|
||||||
|
│ BoxServerHandler (Action RPC 处理, INIT 配置注入) │
|
||||||
|
│ │ │
|
||||||
|
│ BoxRuntime (session 管理 / 进程生命周期 / TTL reaper) │
|
||||||
|
│ │ └─ session.managed_processes: dict[pid, _ManagedProcess]
|
||||||
|
│ │ │
|
||||||
|
│ Backend (启动时根据 box.backend 配置选择): │
|
||||||
|
│ DockerBackend ──┐ │
|
||||||
|
│ PodmanBackend ──┤── CLISandboxBackend │
|
||||||
|
│ NsjailBackend ──┘ (本地 CLI 或 fallback 到容器内 CLI) │
|
||||||
|
│ E2BBackend (云沙箱, 需要 E2B_API_KEY) │
|
||||||
|
│ │
|
||||||
|
│ BoxSkillStore │
|
||||||
|
│ ├─ list / get / create / update / delete │
|
||||||
|
│ ├─ scan_skill_directory / read_skill_file / write_skill_file │
|
||||||
|
│ └─ preview_skill_zip / install_skill_zip (zip 或 GitHub) │
|
||||||
|
│ │
|
||||||
|
│ aiohttp 单端口服务 (默认 :5410): │
|
||||||
|
│ /rpc/ws — Action RPC │
|
||||||
|
│ /v1/sessions/{id}/managed-process/ws — 默认 process │
|
||||||
|
│ /v1/sessions/{id}/managed-process/{pid}/ws — 指定 process │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 容器 / 沙箱 (Docker/Podman 容器, nsjail sandbox, 或 E2B 远程沙箱) │
|
||||||
|
│ - 隔离文件系统 / 网络 / PID 命名空间 │
|
||||||
|
│ - 资源限制 (CPU, 内存, PID 数, 可选 workspace 配额) │
|
||||||
|
│ - 主挂载 (host_path → mount_path) + 任意条 extra_mounts │
|
||||||
|
│ └─ Skills 通过 extra_mounts 挂在 /workspace/.skills/<name> │
|
||||||
|
│ - exec: 用户命令在此执行 │
|
||||||
|
│ - managed process: 多个长驻进程并存 (MCP Server / 自定义服务) │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心设计原则**:
|
||||||
|
- Box Runtime 作为独立进程运行,通过 Action RPC 与 LangBot 主进程通信,两者复用 SDK 的 IO 层(Handler → Connection → Controller)
|
||||||
|
- 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process
|
||||||
|
- Skill / 默认 exec / MCP Server 共享同一个 session 容器(详见 [box-session-scope.md](./box-session-scope.md))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. LangBot 侧模块
|
||||||
|
|
||||||
|
### 2.1 BoxService (`pkg/box/service.py`, 722 行)
|
||||||
|
|
||||||
|
应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Session 模板:
|
||||||
|
|
||||||
|
主要公开方法(按定义顺序):
|
||||||
|
|
||||||
|
```
|
||||||
|
BoxService
|
||||||
|
├─ initialize() 连接 Box Runtime + 默认 workspace 准备
|
||||||
|
├─ _on_runtime_disconnect(connector) 触发重连
|
||||||
|
├─ _reconnect_loop(connector) 指数退避重连
|
||||||
|
├─ available (property) 连接状态
|
||||||
|
│
|
||||||
|
├─ resolve_box_session_id(query) 从 pipeline 模板解析 session_id
|
||||||
|
├─ build_skill_extra_mounts(query) 组装 pipeline-bound skill 的挂载列表
|
||||||
|
│
|
||||||
|
├─ execute_tool(parameters, query) Agent 调用 exec 时的入口
|
||||||
|
│ ├─ _apply_profile / build_spec
|
||||||
|
│ ├─ _validate_host_mount
|
||||||
|
│ ├─ _enforce_workspace_quota (phase=pre)
|
||||||
|
│ ├─ client.execute(spec)
|
||||||
|
│ ├─ _enforce_workspace_quota (phase=post)
|
||||||
|
│ └─ _truncate (stdout/stderr)
|
||||||
|
│
|
||||||
|
├─ execute_spec_payload(spec_payload, ...) 内部入口(其他 loader 调用)
|
||||||
|
├─ create_session(spec_payload, ...) 显式创建 session
|
||||||
|
├─ start_managed_process(session_id, ...) 启动 managed process
|
||||||
|
├─ get_managed_process(session_id, pid) 查询进程状态(pid 默认 'default')
|
||||||
|
├─ stop_managed_process(session_id, pid) 单独停止某个 managed process
|
||||||
|
├─ get_managed_process_websocket_url(...) 返回 WS attach URL
|
||||||
|
│
|
||||||
|
├─ list_skills() / get_skill(name) Skill 元数据
|
||||||
|
├─ create_skill / update_skill / delete_skill Skill CRUD
|
||||||
|
├─ scan_skill_directory(path) 扫描目录
|
||||||
|
├─ list_skill_files / read_skill_file / write_skill_file
|
||||||
|
├─ preview_skill_zip / install_skill_zip zip / GitHub 安装
|
||||||
|
│
|
||||||
|
├─ shutdown() / dispose() 清理:RPC SHUTDOWN + 进程终止
|
||||||
|
├─ get_status() / get_sessions() / get_recent_errors()
|
||||||
|
└─ get_system_guidance() LLM 系统提示
|
||||||
|
```
|
||||||
|
|
||||||
|
**Profile 系统**: 4 个内置 Profile(`default` / `offline_readonly` / `network_basic` / `network_extended`),`locked` frozenset 字段不可被 LLM 覆盖。参数合并顺序:Profile defaults → LLM 请求参数 → locked 强制值。
|
||||||
|
|
||||||
|
**输出截断**: 默认 4000 字符上限,保留前 60% + 后 40%,中间插入 `[...truncated...]`。
|
||||||
|
|
||||||
|
**Skill 挂载合并**: `execute_tool()` 调用时,`build_skill_extra_mounts(query)` 会把当前 pipeline-bound 的所有 skill 的 `package_root` 作为 `extra_mounts` 加入 BoxSpec,挂在 `/workspace/.skills/<name>`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径。
|
||||||
|
|
||||||
|
### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 357 行)
|
||||||
|
|
||||||
|
管理与 Box Runtime 的通信连接:
|
||||||
|
|
||||||
|
- **本地 stdio**: Unix/macOS 默认路径,fork `python -m langbot_plugin.cli.__init__ box -s --ws-control-port {port}` 子进程(与 plugin runtime 统一走 `lbp` CLI 入口)
|
||||||
|
- **本地 subprocess + WS**: Windows 本地(asyncio ProactorEventLoop 不支持 stdio pipe)
|
||||||
|
- **远程 WebSocket**: Docker 部署 / `box.runtime.endpoint` 显式配置时,连接 `ws://{host}:{port}/rpc/ws`
|
||||||
|
- **同步等待**: `asyncio.Event` + `wait_for(timeout=30s)` 模式确认连接
|
||||||
|
- **心跳**: `_heartbeat_loop()` 每 20s 调用 `ping()`,失败仅 DEBUG 日志(断开检测靠 connection close)
|
||||||
|
- **重连**: `runtime_disconnect_callback` 由 BoxService 提供,触发 `_reconnect_loop`
|
||||||
|
- **INIT 注入**: 连接建立后立即下发当前 `box.*` 配置子树(剔除 `runtime` 私有字段),Runtime 据此初始化 backend
|
||||||
|
|
||||||
|
> **历史改进**: 2026-04-16 版本本文档曾列 P0 「Box 无心跳 / 无重连」,已修复(commit `2dfd9d5d`、`c6882cf`、`5029d9c` 等)。
|
||||||
|
|
||||||
|
### 2.3 BoxWorkspaceSession 工具 (`pkg/box/workspace.py`, 413 行)
|
||||||
|
|
||||||
|
此文件目前提供两类能力:
|
||||||
|
|
||||||
|
1. **路径与命令重写工具函数** — `normalize_host_path` / `rewrite_mounted_path` / `unwrap_venv_path` / `rewrite_venv_command` / `infer_workspace_host_path`,被 MCP loader 与 Skill 路径解析共用。
|
||||||
|
2. **`BoxWorkspaceSession`** — 围绕 BoxService 的轻量包装,专供 MCP-in-Box 场景使用(管理一个共享 session 的 session_id、构建挂载 payload、stage host 文件到共享 workspace)。
|
||||||
|
|
||||||
|
**变化点**: 早期 Skill exec 会为每个 skill 创建独立 BoxWorkspaceSession(独占 session);当前实现已转为 `extra_mounts` 模式,Skill 不再独占容器,只追加挂载。这部分 wrapping 逻辑已从 native loader 移除。
|
||||||
|
|
||||||
|
### 2.4 policy.py (`pkg/box/policy.py`, 98 行) — 仍是死代码
|
||||||
|
|
||||||
|
三层安全策略设计(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy`),全项目无任何导入或调用。详见 [SaaS 阻塞项 S2](./box-issues.md)。
|
||||||
|
|
||||||
|
### 2.5 SkillManager (`pkg/skill/manager.py`, 186 行)
|
||||||
|
|
||||||
|
```
|
||||||
|
SkillManager
|
||||||
|
├─ initialize() 调用 reload_skills()
|
||||||
|
├─ reload_skills() 先从 Box runtime list_skills(),
|
||||||
|
│ 不可用则回落 data/skills/ 扫描
|
||||||
|
├─ refresh_skill_from_disk() 单 skill 重新加载
|
||||||
|
├─ get_skill_by_name(name)
|
||||||
|
└─ get_managed_skills_root() 返回 Box 视角的 skills_root 路径
|
||||||
|
```
|
||||||
|
|
||||||
|
skill 元数据通过 `parse_frontmatter` 解析 `SKILL.md` 头部(`name` / `description` / `instructions`),不再做整体扫描的代价(典型 < 50 个)。
|
||||||
|
|
||||||
|
### 2.6 Skill activation (`pkg/skill/activation.py`, 33 行) + Skill loader 辅助
|
||||||
|
|
||||||
|
历史上 skill 通过 LLM 在文本中输出 `[ACTIVATE_SKILL:name]` 标记激活;当前已改为 **Tool Call 机制**:
|
||||||
|
|
||||||
|
- `SkillToolLoader` (`pkg/provider/tools/loaders/skill.py`, 157 行) 暴露 `activate` 工具,参数为 skill 名
|
||||||
|
- 工具实现调用 `register_activated_skill(query, skill_data)`,将激活态写入 `query.variables['_activated_skills']`
|
||||||
|
- 这种 KV-cache-friendly 模式对齐 Claude Code 设计;详见 [box-session-scope.md §4.3](./box-session-scope.md) 的 Tool Call 描述
|
||||||
|
|
||||||
|
`activation.py` 现仅保留对外辅助函数(pipeline 层调用 loader 的 `register_activated_skill`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. SDK 侧模块
|
||||||
|
|
||||||
|
### 3.1 BoxRuntime (`box/runtime.py`, 599 行)
|
||||||
|
|
||||||
|
核心编排器,管理 session 生命周期与 backend 调度:
|
||||||
|
|
||||||
|
```
|
||||||
|
Session 生命周期:
|
||||||
|
|
||||||
|
Client EXEC / CREATE_SESSION
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
_get_or_create_session(spec)
|
||||||
|
├─ _reap_expired_sessions_locked() 清理 TTL 过期 session
|
||||||
|
├─ 已存在? → _assert_session_compatible() → 复用
|
||||||
|
├─ Backend session 失踪? → 重建 (commit c6882cf)
|
||||||
|
└─ 新建? → backend.start_session(spec) → 创建容器
|
||||||
|
│ └─ 应用 spec.extra_mounts (多挂载)
|
||||||
|
▼
|
||||||
|
execute(spec)
|
||||||
|
├─ 获取 session lock (每 session 独立)
|
||||||
|
├─ backend.exec(session, spec) 在容器中执行命令
|
||||||
|
├─ 更新 last_used_at
|
||||||
|
└─ 超时? → 销毁 session
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Session 保持存活直到:
|
||||||
|
├─ TTL 过期 (默认 300s,下次操作时清理)
|
||||||
|
├─ 执行超时 (自动销毁)
|
||||||
|
├─ 客户端 DELETE_SESSION
|
||||||
|
└─ SHUTDOWN
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键设计**:
|
||||||
|
- 每 session 有独立 `asyncio.Lock`,同一 session 内的命令串行执行
|
||||||
|
- 每 session 维护 `managed_processes: dict[process_id, _ManagedProcess]`,支持多个长驻进程并存(MCP / 自定义)
|
||||||
|
- 全局 `_lock` 保护 `_sessions` dict 的读写
|
||||||
|
- 兼容性检查:比较核心 spec 字段,`image` 字段对不支持自定义镜像的 backend(nsjail/E2B)会跳过
|
||||||
|
|
||||||
|
**Backend 选择 (`_select_backend`)**: 优先级
|
||||||
|
1. 显式 `box.backend` 配置(`docker` / `nsjail` / `e2b`)
|
||||||
|
2. `local` (默认) → Docker / Podman / nsjail CLI 顺序探测
|
||||||
|
3. `get_status` 调用时若当前 backend 不可用,会尝试重新选择 (commit `e5617c7`)
|
||||||
|
|
||||||
|
### 3.2 Backend 系统
|
||||||
|
|
||||||
|
#### CLISandboxBackend (`box/backend.py`, 411 行)
|
||||||
|
|
||||||
|
Docker / Podman 公共基类:
|
||||||
|
|
||||||
|
```
|
||||||
|
start_session(spec):
|
||||||
|
1. validate_sandbox_security(spec)
|
||||||
|
2. docker/podman run -d --rm --name <name>
|
||||||
|
--network none (可选)
|
||||||
|
--cpus/--memory/--pids-limit
|
||||||
|
--read-only + --tmpfs /tmp
|
||||||
|
-v <host>:<mount>:<mode> 主挂载
|
||||||
|
-v <extra.host>:<extra.mount>:.. 额外挂载 (extra_mounts)
|
||||||
|
<image> sh -lc 'while true; do sleep 3600; done'
|
||||||
|
3. 返回 BoxSessionInfo
|
||||||
|
|
||||||
|
exec(session, spec):
|
||||||
|
docker/podman exec -e KEY=VAL <container>
|
||||||
|
sh -lc 'mkdir -p <workdir> && cd <workdir> && <cmd>'
|
||||||
|
|
||||||
|
start_managed_process(session, spec):
|
||||||
|
docker/podman exec -i <container>
|
||||||
|
sh -lc 'mkdir -p <cwd> && cd <cwd> && exec <command> <args>'
|
||||||
|
返回 asyncio.subprocess.Process (stdin/stdout PIPE)
|
||||||
|
```
|
||||||
|
|
||||||
|
容器以 idle 进程启动,实际命令通过 `docker exec` 执行。`--rm` 确保容器退出时自动清理。
|
||||||
|
|
||||||
|
**Windows 支持**: backend 内对 Windows 路径处理与 subprocess 调用做了适配(commit `120817a`)。
|
||||||
|
|
||||||
|
**孤儿清理**: 启动时枚举 `langbot.box=true` 标签的容器,instance_id 不匹配的强制删除。
|
||||||
|
|
||||||
|
#### NsjailBackend (`box/nsjail_backend.py`, 552 行)
|
||||||
|
|
||||||
|
轻量级 Linux 沙箱(无容器引擎依赖):
|
||||||
|
|
||||||
|
- 使用 namespace 隔离(user/mount/pid/ipc/uts/cgroup/net)
|
||||||
|
- 挂载宿主 `/usr`/`/lib`/`/bin`/`/sbin` 只读 + 选定 `/etc` 条目
|
||||||
|
- 每 session 创建独立目录(workspace/tmp/home)
|
||||||
|
- 资源限制: cgroup v2 优先,fallback 到 rlimit
|
||||||
|
- **CLI 兼容**: 通过 `shutil.which(self._nsjail_bin)` 检测系统安装版 nsjail;不存在时再尝试容器内 nsjail(commit `686fcc0`、`feed530`)
|
||||||
|
- **无自定义镜像**: 使用宿主 OS,`image` 字段固定为 `'host'`,兼容性检查跳过 image
|
||||||
|
|
||||||
|
#### E2BBackend (`box/e2b_backend.py`, 429 行)
|
||||||
|
|
||||||
|
云沙箱后端(commit `75b547f` 引入):
|
||||||
|
|
||||||
|
- 通过 `e2b` SDK 与 E2B 平台通信
|
||||||
|
- 配置:`box.e2b.api_key` / `api_url` / `template`
|
||||||
|
- 支持 `extra_mounts`(commit `0fea9b1` 同步上传文件)
|
||||||
|
- 无本地容器引擎依赖,适合无 Docker 的部署或 SaaS 多租户场景
|
||||||
|
- 不支持自定义 image 字段,由 template 控制
|
||||||
|
|
||||||
|
### 3.3 Server (`box/server.py`, 508 行)
|
||||||
|
|
||||||
|
单端口 aiohttp 服务(默认 5410),通过路径区分(commit `8c71ec5` 合并端口):
|
||||||
|
|
||||||
|
1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理所有 action,包括 `INIT` 配置注入、skill store 操作等
|
||||||
|
2. **WS Relay** (`/v1/sessions/{id}/managed-process/ws` 与 `/v1/sessions/{id}/managed-process/{pid}/ws`): 双向桥接 WebSocket ↔ 指定 managed process stdin/stdout
|
||||||
|
|
||||||
|
stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process attach;Action RPC 走 stdin/stdout。
|
||||||
|
|
||||||
|
### 3.4 Client (`box/client.py`, 377 行)
|
||||||
|
|
||||||
|
`ActionRPCBoxClient` 封装 `Handler.call_action()` 调用:
|
||||||
|
|
||||||
|
- 25+ 方法对应 25+ 个 RPC action(exec / session / managed-process / skill / status / shutdown)
|
||||||
|
- 错误还原: `_translate_action_error()` 通过字符串前缀匹配还原 SDK 侧异常类型
|
||||||
|
- `execute()` timeout = 300s,其他默认 15s
|
||||||
|
- `BoxRuntimeClient` 是 ABC,供后续可能的非 RPC 实现复用
|
||||||
|
|
||||||
|
包级别 `__init__.py` 显式导出:`BoxRuntimeClient`、`ActionRPCBoxClient`(commit `df9c722`)。
|
||||||
|
|
||||||
|
### 3.5 Actions (`box/actions.py`, 34 行)
|
||||||
|
|
||||||
|
`LangBotToBoxAction` 枚举共定义 **25 个** action:
|
||||||
|
|
||||||
|
| 类别 | Actions |
|
||||||
|
|------|---------|
|
||||||
|
| 控制 | `INIT`、`HEALTH`、`STATUS`、`GET_BACKEND_INFO`、`SHUTDOWN` |
|
||||||
|
| 执行 | `EXEC` |
|
||||||
|
| Session | `CREATE_SESSION` / `GET_SESSION` / `GET_SESSIONS` / `DELETE_SESSION` |
|
||||||
|
| Managed Process | `START_MANAGED_PROCESS` / `GET_MANAGED_PROCESS` / `STOP_MANAGED_PROCESS` |
|
||||||
|
| Skill | `LIST_SKILLS` / `GET_SKILL` / `CREATE_SKILL` / `UPDATE_SKILL` / `DELETE_SKILL` / `SCAN_SKILL_DIRECTORY` / `LIST_SKILL_FILES` / `READ_SKILL_FILE` / `WRITE_SKILL_FILE` / `PREVIEW_SKILL_ZIP` / `INSTALL_SKILL_ZIP` |
|
||||||
|
|
||||||
|
### 3.6 Models (`box/models.py`, 331 行)
|
||||||
|
|
||||||
|
核心数据模型:
|
||||||
|
|
||||||
|
| 模型 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `BoxNetworkMode` | `OFF` / `ON` |
|
||||||
|
| `BoxExecutionStatus` | `COMPLETED` / `TIMED_OUT` |
|
||||||
|
| `BoxHostMountMode` | `NONE` / `READ_ONLY` / `READ_WRITE` |
|
||||||
|
| `BoxManagedProcessStatus` | `RUNNING` / `EXITED` |
|
||||||
|
| `BoxMountSpec` | 单条挂载(host_path/mount_path/mode)— **新增** |
|
||||||
|
| `BoxSpec` | 执行请求;新增 `extra_mounts: list[BoxMountSpec]`、`persistent`、`workspace_quota_mb` |
|
||||||
|
| `BoxProfile` | 4 个内置 Profile + `locked` frozenset |
|
||||||
|
| `BoxSessionInfo` | Session 状态(含 backend_name/created_at/last_used_at) |
|
||||||
|
| `BoxManagedProcessSpec` | 长驻进程参数(process_id/command/args/env/cwd) |
|
||||||
|
| `BoxManagedProcessInfo` | 进程状态(status/exit_code/stderr_preview/attached) |
|
||||||
|
| `BoxExecutionResult` | 执行结果(status/exit_code/stdout/stderr/duration_ms) |
|
||||||
|
|
||||||
|
`BoxSpec` 校验器: `workdir` 默认继承 `mount_path`;`host_path` 支持 POSIX 和 Windows 路径;设置 `host_path` 时 `workdir` 必须在 `mount_path` 下。
|
||||||
|
|
||||||
|
### 3.7 BoxSkillStore (`box/skill_store.py`, 647 行)
|
||||||
|
|
||||||
|
新增模块(commit `4ab3502`),把 skill 持久化收归 Box runtime:
|
||||||
|
|
||||||
|
```
|
||||||
|
BoxSkillStore
|
||||||
|
├─ list_skills() / get_skill(name)
|
||||||
|
├─ create_skill(data) / update_skill(name, data) / delete_skill(name)
|
||||||
|
├─ scan_skill_directory(path) 扫描目录返回候选 skill 包列表
|
||||||
|
├─ list_skill_files(name, path) 浏览 skill 内文件树
|
||||||
|
├─ read_skill_file(name, path) / write_skill_file(name, path, content)
|
||||||
|
├─ preview_skill_zip(zip_bytes, ...) 不落盘预览 zip 内容
|
||||||
|
└─ install_skill_zip(zip_bytes, ...) 解压、校验、复制到 skills_root
|
||||||
|
└─ 支持 source_subdir / target_suffix(commit 1aa043f)
|
||||||
|
```
|
||||||
|
|
||||||
|
GitHub 安装路径:HTTP 层(`api/http/service/skill.py`)先 `git clone` 拉取,再走 `install_skill_zip` 或 directory 路径。Skill 文件存放于 `box.local.skills_root`(默认 `skills`,相对 `host_root`),容器内对应 `/workspace/.skills/`。
|
||||||
|
|
||||||
|
### 3.8 Security (`box/security.py`, 52 行)
|
||||||
|
|
||||||
|
`validate_sandbox_security()`: 黑名单校验 host_path,阻止挂载 `/etc`/`/proc`/`/sys`/`/dev`/`/root`/`/boot` 及 Docker/Podman socket。
|
||||||
|
|
||||||
|
**已知缺陷**: 根路径 `/` 未拦截,用户 home 目录未拦截,是 denylist 而非 allowlist 策略。详见 [SaaS 阻塞项 S5](./box-issues.md)。
|
||||||
|
|
||||||
|
### 3.9 Errors (`box/errors.py`, 33 行)
|
||||||
|
|
||||||
|
| 异常类型 | 含义 |
|
||||||
|
|----------|------|
|
||||||
|
| `BoxError` | 基类 |
|
||||||
|
| `BoxValidationError` | spec/参数校验失败 |
|
||||||
|
| `BoxBackendUnavailableError` | 无可用 backend |
|
||||||
|
| `BoxRuntimeUnavailableError` | Runtime 服务不可用 |
|
||||||
|
| `BoxSessionConflictError` | session 已存在但 spec 不兼容 |
|
||||||
|
| `BoxSessionNotFoundError` | session 不存在 |
|
||||||
|
| `BoxManagedProcessConflictError` | session 已有同名 process |
|
||||||
|
| `BoxManagedProcessNotFoundError` | process 不存在 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 工具系统集成
|
||||||
|
|
||||||
|
### 4.1 ToolManager 编排 (`toolmgr.py`)
|
||||||
|
|
||||||
|
```
|
||||||
|
ToolManager.initialize()
|
||||||
|
├─ NativeToolLoader (exec / read / write / edit / glob / grep)
|
||||||
|
├─ PluginToolLoader (插件工具)
|
||||||
|
├─ MCPLoader (MCP Server 工具)
|
||||||
|
├─ SkillToolLoader (activate 工具 — Tool Call 激活)
|
||||||
|
└─ SkillAuthoringToolLoader (Skill CRUD)
|
||||||
|
|
||||||
|
工具调用优先级: native → plugin → mcp → skill → skill_authoring
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Native Tools (`native.py`, 846 行)
|
||||||
|
|
||||||
|
| 工具 | 是否在 Box 中执行 | 是否访问宿主文件系统 |
|
||||||
|
|------|:---:|:---:|
|
||||||
|
| `exec` | 是 | 否 |
|
||||||
|
| `read` | **否** | **是** — 直接 `open()` 宿主文件 |
|
||||||
|
| `write` | **否** | **是** — 直接 `open()` 宿主文件 |
|
||||||
|
| `edit` | **否** | **是** — 直接 `open()` 宿主文件 |
|
||||||
|
| `glob` | **否** | **是** — 直接遍历宿主目录 |
|
||||||
|
| `grep` | **否** | **是** — 直接读宿主文件 |
|
||||||
|
|
||||||
|
**沙箱边界不对称**: 这是刻意的设计权衡 — `read`/`write`/`edit`/`glob`/`grep` 绕过沙箱以获得性能(避免容器 I/O 开销与跨进程拷贝),但意味着 LLM 可以直接读写 `allowed_mount_roots` 下任何文件。Skill 路径经 `_resolve_host_path()` 重写,禁止穿越 `package_root`。
|
||||||
|
|
||||||
|
**exec 的 Skill 分支**: 命令中引用 `/workspace/.skills/<name>` 的 skill 时:
|
||||||
|
1. 验证 skill 已激活
|
||||||
|
2. 单次 exec 只能引用一个 skill 包
|
||||||
|
3. 若 skill 是 Python 项目(有 `requirements.txt` 或 `pyproject.toml`),命令会被 venv bootstrap 包裹(在 skill 挂载点内创建 `.venv`)
|
||||||
|
4. 调用 `box_service.execute_tool()` → 走默认 session_id 与已组装好的 `extra_mounts`,**不再为每 skill 起独立 session**
|
||||||
|
|
||||||
|
### 4.3 MCP-in-Box (`mcp_stdio.py`, 354 行)
|
||||||
|
|
||||||
|
`BoxStdioSessionRuntime` 让 MCP stdio 服务器在 Box 容器中运行,**共享 session、多 process**模式(commit `529088e`):
|
||||||
|
|
||||||
|
```
|
||||||
|
initialize()
|
||||||
|
1. 复用/创建共享 session (session_id = _build_box_session_id())
|
||||||
|
- persistent=True,长期保持
|
||||||
|
2. workspace.execute_raw(install_cmd) 安装依赖 (可选)
|
||||||
|
3. 将每个 MCP server 文件 stage 到 /workspace/.mcp/<process_id>/
|
||||||
|
4. workspace.start_managed_process(process_id=<server>)
|
||||||
|
5. websocket_client(ws_url) 通过 WS relay 连接
|
||||||
|
6. ClientSession.initialize() MCP 协议握手
|
||||||
|
```
|
||||||
|
|
||||||
|
配置 (`MCPServerBoxConfig`): `network='on'` (MCP 服务器通常需要网络),`host_path_mode='ro'` (默认只读),`startup_timeout_sec=120` (留时间给 pip install)。
|
||||||
|
|
||||||
|
每条 MCP server 是同一 session 中的一个 managed process,独立的 `process_id`、独立 attach URL,互不阻塞。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 启动与生命周期
|
||||||
|
|
||||||
|
### 5.1 启动顺序 (`build_app.py`)
|
||||||
|
|
||||||
|
```
|
||||||
|
BuildAppStage.run(ap)
|
||||||
|
├─ ... (persistence, models, sessions) ...
|
||||||
|
│
|
||||||
|
├─ BoxService(ap)
|
||||||
|
├─ box_service.initialize()
|
||||||
|
│ └─ connector.initialize()
|
||||||
|
│ ├─ [stdio] fork box subprocess
|
||||||
|
│ ├─ [subprocess+WS] Windows 本地
|
||||||
|
│ └─ [remote WS] connect URL
|
||||||
|
│ └─ 启动心跳 _heartbeat_task
|
||||||
|
├─ ap.box_service = box_service
|
||||||
|
│
|
||||||
|
├─ ToolManager(ap)
|
||||||
|
├─ tool_mgr.initialize()
|
||||||
|
│ ├─ NativeToolLoader (检查 box_service.available)
|
||||||
|
│ ├─ PluginToolLoader
|
||||||
|
│ ├─ MCPLoader (Box 可用时,stdio MCP 走沙箱)
|
||||||
|
│ └─ SkillAuthoringToolLoader
|
||||||
|
├─ ap.tool_mgr = tool_mgr
|
||||||
|
│
|
||||||
|
├─ ... (platform, pipeline) ...
|
||||||
|
├─ SkillManager.initialize() (从 Box runtime 加载 skill 列表)
|
||||||
|
└─ ... (RAG, HTTP, plugins) ...
|
||||||
|
```
|
||||||
|
|
||||||
|
BoxService 在 ToolManager **之前**初始化。ToolManager 创建 loader 时检查 `box_service.available`。
|
||||||
|
|
||||||
|
### 5.2 初始化失败处理
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
await self._runtime_connector.initialize()
|
||||||
|
self._available = True
|
||||||
|
except Exception as e:
|
||||||
|
self._available = False
|
||||||
|
logger.warning(f"Box runtime unavailable: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**静默降级**: Box 初始化失败不会阻止应用启动,仅导致 6 个 native tool、所有 Skill 工具和 MCP-in-Box 工具不暴露给 LLM。与 Plugin 的行为不同(Plugin 失败会抛异常)。
|
||||||
|
|
||||||
|
### 5.3 销毁流程
|
||||||
|
|
||||||
|
```
|
||||||
|
app.dispose()
|
||||||
|
└─ box_service.dispose()
|
||||||
|
├─ connector.dispose()
|
||||||
|
│ ├─ cancel _heartbeat_task
|
||||||
|
│ ├─ cancel _handler_task / _ctrl_task
|
||||||
|
│ └─ terminate subprocess (SIGTERM)
|
||||||
|
└─ loop.create_task(client.shutdown())
|
||||||
|
└─ RPC SHUTDOWN → Box Runtime 清理所有容器
|
||||||
|
```
|
||||||
|
|
||||||
|
Box 额外做了 RPC SHUTDOWN 通知 Runtime 主动清理容器,比 Plugin 的直接杀进程更安全。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 配置
|
||||||
|
|
||||||
|
### config.yaml (重构后)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
box:
|
||||||
|
enabled: true # 整个 Box 子系统的总开关。设为 false 时:
|
||||||
|
# - 不连接远程 Box runtime,不 fork 本地 stdio 子进程
|
||||||
|
# - sandbox 工具 (exec/read/write/edit/glob/grep) 不暴露给 LLM
|
||||||
|
# - skill 添加/编辑 / GitHub 安装 / 文件写入全部拒绝
|
||||||
|
# - stdio 模式的 MCP server 启动时报错(http/sse 模式不受影响)
|
||||||
|
# - skill 列表/读取保持只读可用
|
||||||
|
# BOX__ENABLED 环境变量可覆盖(统一约定)
|
||||||
|
backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b'
|
||||||
|
# 由 box.backend / BOX__BACKEND 选择后端
|
||||||
|
runtime:
|
||||||
|
endpoint: '' # 外部 Runtime 的 WS 基地址 'ws://host:5410'
|
||||||
|
# 留空 = 本地自管 Runtime
|
||||||
|
local:
|
||||||
|
profile: 'default'
|
||||||
|
image: '' # 覆盖 profile 默认 image
|
||||||
|
host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径
|
||||||
|
default_workspace: '' # 默认 '<host_root>/default'
|
||||||
|
skills_root: 'skills' # Box 管理的 skill 包目录(相对 host_root)
|
||||||
|
allowed_mount_roots: # 默认 ['<host_root>']
|
||||||
|
- './data/box'
|
||||||
|
- '/tmp'
|
||||||
|
workspace_quota_mb: null # 配额覆盖,null = 走 profile
|
||||||
|
e2b:
|
||||||
|
api_key: '' # 也可走 E2B_API_KEY 环境变量
|
||||||
|
api_url: '' # 自托管 E2B 时填写
|
||||||
|
template: '' # 默认 template ID
|
||||||
|
```
|
||||||
|
|
||||||
|
> **重大变更**: 较 2026-04-16 文档,配置结构完全重组(commit `eefdea4`)。原字段 `box.profile` / `box.runtime_url` / `box.shared_host_root` / `box.allowed_host_mount_roots` 全部迁入 `box.local.*` 子表,新增 `box.backend` 与 `box.e2b.*` 配置组。
|
||||||
|
|
||||||
|
### docker-compose.yaml
|
||||||
|
|
||||||
|
`langbot_box` 服务受 compose profile 控制,默认 `docker compose up` **不会**启动它。需要 sandbox 时:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile box up # 启动 langbot + langbot_box + plugin runtime
|
||||||
|
docker compose --profile all up # 同上
|
||||||
|
docker compose up # 只起 langbot + plugin runtime (box 关闭)
|
||||||
|
```
|
||||||
|
|
||||||
|
若不起 `langbot_box`,需要同步在 `data/config.yaml` 中设 `box.enabled: false`(或 langbot 容器 env 加 `BOX__ENABLED=false`),否则 LangBot 会一直尝试连接不存在的 Box runtime 并报错。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# langbot_box 的关键 volume
|
||||||
|
volumes:
|
||||||
|
- ${LANGBOT_BOX_ROOT}:${LANGBOT_BOX_ROOT} # 工作区挂载(源/目标同路径)
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock # Docker backend 复用宿主 docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关闭/连接失败时的行为矩阵
|
||||||
|
|
||||||
|
`box.enabled = false` 与"启用但连接失败"在用户可观察行为上**完全一致**——都通过 `BoxService.available = False` 表达,只是 `get_status` 多返回 `enabled` 字段供前端区分文案。
|
||||||
|
|
||||||
|
| 消费方 | Box 可用 | Box 不可用(disabled 或 failed) |
|
||||||
|
|---|---|---|
|
||||||
|
| native exec/read/write/edit/glob/grep 工具 | 暴露给 LLM | **不暴露** |
|
||||||
|
| `activate` / `register_skill` 工具 | 暴露给 LLM | **不暴露** |
|
||||||
|
| stdio MCP server | 在 Box 内启动 | **`_init_stdio_python_server` 抛 RuntimeError** 拒绝;不退化到宿主 stdio |
|
||||||
|
| http/sse MCP server | 正常 | 正常(不依赖 Box) |
|
||||||
|
| Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Box runtime | 走 LangBot 本地 `data/skills/` 只读 fallback |
|
||||||
|
| Skill 创建/编辑/安装/写文件 | 走 Box runtime | **HTTP 400** + 明确错误信息(`_require_box_for_write`) |
|
||||||
|
| Pipeline AI 配置中 `box-session-id-template` | 正常生效 | **前端 banner** 提示字段无效 |
|
||||||
|
| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | **前端禁用** + banner |
|
||||||
|
| 仪表盘 Box 状态卡片 | 绿点 / "已连接" | 灰点 / "已禁用"(disabled) 或 红点 / "已断开"(failed) |
|
||||||
|
|
||||||
|
> 后端拒写的边界条件:如果 `ap.box_service` **完全没装**(老式 dev mode,没经过 BuildAppStage),`_require_box_for_write` 视作 no-op,保留 `data/skills/` 本地路径——以兼容历史测试与最小化设置。生产环境总会装 `ap.box_service`,因此该 fallback 不会被触发。
|
||||||
|
|
||||||
|
### Pipeline 配置 (templates/metadata/pipeline/ai.yaml)
|
||||||
|
|
||||||
|
`local-agent.config.box-session-id-template` 控制 session 作用域,预设:
|
||||||
|
|
||||||
|
- `{launcher_type}_{launcher_id}` — 每个会话 (推荐,默认)
|
||||||
|
- `{launcher_type}_{launcher_id}_{sender_id}` — 群聊每个用户
|
||||||
|
- `{launcher_type}_{launcher_id}_{conversation_id}` — 每个对话上下文
|
||||||
|
- `{query_id}` — 每条消息(完全隔离)
|
||||||
|
|
||||||
|
详见 [box-session-scope.md](./box-session-scope.md)。
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
| 端点 | 方法 | 说明 | 前端 |
|
||||||
|
|------|------|------|:---:|
|
||||||
|
| `/api/v1/box/status` | GET | 可用性、Profile、后端信息 | ✅ 监控页 |
|
||||||
|
| `/api/v1/box/sessions` | GET | 活跃 session 列表 | ❌ |
|
||||||
|
| `/api/v1/box/errors` | GET | 最近 50 条错误 | ❌ |
|
||||||
|
| `/api/v1/skills` 等 | GET/POST/PUT/DELETE | Skill CRUD、文件浏览、zip/GitHub 安装、preview | ✅ Skill 管理页 |
|
||||||
|
|
||||||
|
前端 `web/src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx` 已接入 `/api/v1/box/status`,展示 backend 名称、profile 与活跃 session 数。Sessions 与 errors API 仍未接入。
|
||||||
76
docs/review/box-issues.md
Normal file
76
docs/review/box-issues.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Box 系统 — SaaS 发布前阻塞项
|
||||||
|
|
||||||
|
> 更新日期: 2026-06-02
|
||||||
|
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
|
> 相关文档: [架构分析](./box-architecture.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md)
|
||||||
|
|
||||||
|
## 范围说明
|
||||||
|
|
||||||
|
**自部署社区版已具备发布条件**:默认 stdio 模式、box 为可选项;box 关闭 / 不可用时后端、前端、工具、skill、stdio-MCP 均能干净降级(清晰报错、不崩溃);配置向后兼容(旧 `data/config.yaml` 可直接启动);无新增 ORM 模型、无迁移欠债;市场安装失败不会破坏实例。CI 全绿。
|
||||||
|
|
||||||
|
本清单**只保留发布 SaaS / 多租户 / 公网暴露前必须处理的阻塞项**。社区版(可信、单运营者、内网)不受这些项阻塞——它们的风险面在"不可信调用方能直接触达 Box 控制面"或"多租户共享资源"的场景才成立。
|
||||||
|
|
||||||
|
## 已解决(社区版发布前)
|
||||||
|
|
||||||
|
| 项 | 处理 |
|
||||||
|
|----|------|
|
||||||
|
| 工具调用循环无上限 (原 #13) | `localagent.py` 增加 `MAX_TOOL_CALL_ROUNDS=128`,超限优雅终止(`cafef1a3`) |
|
||||||
|
| 配额校验同步遍历阻塞事件循环 (原 #10) | `_enforce_workspace_quota` 改 async,工作区遍历走 `asyncio.to_thread`(`cafef1a3`) |
|
||||||
|
| `host_path` 挂载白名单 (原 #3 的 LangBot 侧) | `pkg/box/service.py` `allowed_mount_roots` 白名单,空列表时拒绝一切宿主挂载 |
|
||||||
|
| 重复的 `_is_path_under` (原 #12) | 已去重,仅保留一处定义 |
|
||||||
|
| 重连 / 心跳 / Windows 兼容 / nsjail image 字段 / 前端 Box 状态接入 | 见上一轮 review 记录,均已合入 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SaaS 阻塞项
|
||||||
|
|
||||||
|
### S1. Box 控制面无认证 — Critical
|
||||||
|
|
||||||
|
- **位置**: SDK `box/server.py` — Action RPC WS (`/rpc/ws`) 与 managed-process relay (`/v1/sessions/{id}/managed-process/{pid}/ws`)
|
||||||
|
- **现状**: 两个 WS handler 在 `ws.prepare` 后直接服务,无任何 token / 鉴权;box 默认绑定 `0.0.0.0:5410`。任何能触达该端口者可发起 `EXEC`、创建 session、attach 任意 session 的 managed-process stdin/stdout、甚至 `SHUTDOWN`。LangBot→box 的 INIT 也未下发任何凭证。
|
||||||
|
- **缓解现状**: 默认 `docker-compose.yaml` 的 `langbot_box` 未把 5410 发布到宿主(爆炸半径限于内网 bridge);但 box 挂载了 `/var/run/docker.sock`,同网络的任意服务(含被攻破的插件)→ 宿主 root。若运营者把 5410 发布到宿主或独立以 `0.0.0.0` 起 box,则完全裸奔。
|
||||||
|
- **要求**: INIT 时下发 token,两个 WS 路由按连接校验(query/header)。这是 SaaS 的**头号**阻塞项。
|
||||||
|
|
||||||
|
### S2. 无 exec 授权模型(policy.py 死代码) — High
|
||||||
|
|
||||||
|
- **位置**: LangBot `pkg/box/policy.py`(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy` 全项目无引用);`pkg/provider/tools/loaders/native.py`;`pkg/provider/tools/toolmgr.py`
|
||||||
|
- **现状**: 原生工具(`exec/read/write/edit/glob/grep`)按"box 是否可用"全有或全无地暴露,**无 per-pipeline 的 exec 网关 / 工具白名单 / 沙箱模式 / 权限提升控制**。只要 box 可用,任何使用 local-agent + 函数调用模型的 pipeline 都能跑任意 shell。
|
||||||
|
- **要求**: 接入 policy.py(或等价机制),按 pipeline 控制是否暴露 `exec`、可用工具白名单、沙箱网络/只读模式。
|
||||||
|
|
||||||
|
### S3. 会话资源无界(DoS) — High
|
||||||
|
|
||||||
|
- **#5 session 数量无上限**: SDK `box/runtime.py` `_get_or_create_session` 的 `_sessions` dict 无容量限制——可变 `session_id` 的恶意调用可无限创建容器,耗尽宿主 CPU/内存/PID/磁盘。
|
||||||
|
- **#8 无定时回收**: 过期 session 仅在 `_get_or_create_session` 时机会性清理,无独立周期任务;一波创建后转静默会永久泄漏容器。
|
||||||
|
- **要求**: `max_sessions` 上限(拒绝或 LRU),加独立周期 reaper(如 60s)。
|
||||||
|
|
||||||
|
### S4. 工作区配额无内核级限制(TOCTOU) — Med-High
|
||||||
|
|
||||||
|
- **位置**: LangBot `pkg/box/service.py` `_enforce_workspace_quota`(应用层 read-then-check);SDK 侧 `workspace_quota_mb` 仅记录/透传,无 `--storage-opt size=` 等内核/FS 限额
|
||||||
|
- **现状**: 执行前后两次检查之间存在竞态窗口;单条命令(`dd`/`fallocate`)可在检查间隙撑爆磁盘,事后检查只能补救。
|
||||||
|
- **要求**: Docker `--storage-opt size=` 做内核级限制,或 Redis 原子计数预留式配额。
|
||||||
|
|
||||||
|
### S5. 挂载校验缺口 — Med-High
|
||||||
|
|
||||||
|
- **位置**: SDK `box/security.py` `_BLOCKED_HOST_PATHS_POSIX`;`box/backend.py` 的 `extra_mounts` 处理
|
||||||
|
- **现状**: ① SDK 黑名单仍不含 `/`(前缀匹配,`host_path="/"` 可通过,挂载整个宿主 fs);用户 home、`/usr`、`/opt`、`/tmp` 也未拦截。② `validate_sandbox_security` 只校验 `spec.host_path`,**从不遍历 `spec.extra_mounts`**——LangBot 侧 `allowed_mount_roots` 也只校验 `host_path`。当前 `extra_mounts` 仅由 `build_skill_extra_mounts` 内部填充(agent 不可达),但缺乏纵深防御:一旦 S1 的无认证 RPC 被触达,extra_mounts 可挂任意宿主路径,两层都不拦。
|
||||||
|
- **要求**: SDK 黑名单加入 `/`(或改白名单);`extra_mounts` 在 SDK 与 LangBot 两侧都纳入挂载校验。
|
||||||
|
|
||||||
|
### S6. 容器加固缺失 — Med
|
||||||
|
|
||||||
|
- **位置**: SDK `box/backend.py` 的 `docker run` 组装
|
||||||
|
- **现状**: 未设置 `--cap-drop=ALL`、`--security-opt=no-new-privileges`、非 root `--user`;叠加挂载 docker.sock,逃逸面偏大。
|
||||||
|
- **要求**: 默认加上上述加固 flag(需回归常用 skill 不被破坏)。
|
||||||
|
|
||||||
|
### S7. 全局锁内执行慢操作(扩展性) — Med
|
||||||
|
|
||||||
|
- **位置**: SDK `box/runtime.py` `_get_or_create_session`:`self._lock` 持有期间调用 `backend.start_session()`(`docker run` / nsjail 启动 / E2B `Sandbox.create`)
|
||||||
|
- **影响**: 冷启动(镜像拉取数秒、E2B >1s)期间串行阻塞所有并发请求——多租户负载下整个 Box runtime 停顿。降级表现是延迟而非失败。
|
||||||
|
- **要求**: 锁内只做状态检查与注册,容器创建移到锁外。
|
||||||
|
|
||||||
|
### S8. 其他硬化 / 跟进 — Low
|
||||||
|
|
||||||
|
- **#9** SDK `box/server.py` 直接读 `runtime._sessions` 私有字段、绕过锁,并发下可能读到不一致状态——应加公共访问方法。
|
||||||
|
- **#16** `pkg/provider/tools/toolmgr.py` `execute_func_call` 按优先级分发,plugin/MCP 若有同名 `exec/read/write/...` 工具会被静默遮蔽——应加命名空间或冲突告警。
|
||||||
|
- **#4** SDK `box/runtime.py` INIT/handshake 与 backend 实例化的残留竞态(仅"纯远程 WS box 先启动、LangBot 后连"场景成立;stdio/compose 路径下 config 经 env 在 spawn 时已就位,无竞态)——应在 INIT 完成前拒绝业务 action。
|
||||||
|
- **#11** `extra_mounts` 在容器创建时固定(SDK `runtime.py` 兼容性检查不含 extra_mounts);长生命周期共享 session 后续新激活的 skill 不会挂上(当前缓解:创建时挂上 pipeline 绑定的全部 skill)——动态绑定场景需销毁重建或文档说明。
|
||||||
|
- **#21** 集成测试未进 CI:容器实际执行、E2B 真机、managed-process WS attach 仅本地可跑。安全关键路径缺自动化覆盖——SaaS 前建议加 Docker-in-Docker CI stage 或合并前手动 checklist。
|
||||||
402
docs/review/box-session-scope.md
Normal file
402
docs/review/box-session-scope.md
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
# Box Session Scope Design
|
||||||
|
|
||||||
|
> Date: 2026-04-18 (last reviewed 2026-06-02)
|
||||||
|
> Status (2026-06-02): the self-hosted community edition is release-ready (box optional, clean degradation, no migration debt). Tool-call loop cap, async quota scan, and the host_path mount allowlist have landed. Remaining multi-tenant / security hardening is tracked in [box-issues.md](./box-issues.md).
|
||||||
|
> Branch: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
|
> Related: [Box Architecture](./box-architecture.md) | [Box vs Plugin Runtime](./box-vs-plugin-runtime.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Implementation Status (2026-05-19)
|
||||||
|
|
||||||
|
This document was authored as a design proposal. The current `feat/sandbox` branch
|
||||||
|
has shipped the design largely as written:
|
||||||
|
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `BoxMountSpec` + `BoxSpec.extra_mounts` | ✅ Shipped | SDK `box/models.py` |
|
||||||
|
| Docker / nsjail / E2B backends apply extra mounts | ✅ Shipped | Last gap closed by SDK commit `0fea9b1` (E2B) |
|
||||||
|
| `box-session-id-template` in `local-agent` pipeline config | ✅ Shipped | `templates/metadata/pipeline/ai.yaml`, default `{launcher_type}_{launcher_id}` |
|
||||||
|
| `BoxService.resolve_box_session_id(query)` | ✅ Shipped | `pkg/box/service.py:166` |
|
||||||
|
| `BoxService.build_skill_extra_mounts(query)` | ✅ Shipped | `pkg/box/service.py:189` |
|
||||||
|
| Skill exec uses unified container + extra mounts | ✅ Shipped | `pkg/provider/tools/loaders/native.py` skill branch |
|
||||||
|
| MCP-in-Box uses shared persistent session, multi-process | ✅ Shipped (earlier than originally scoped) | SDK commit `529088e`, LangBot `mcp_stdio.py:_build_box_session_id` |
|
||||||
|
| `BoxManagedProcessSpec.process_id` + multi-process per session | ✅ Shipped | `BoxRuntime` keeps `managed_processes: dict[pid, _ManagedProcess]` |
|
||||||
|
| Per-tenant / quota integration with templates | ❌ Not started | See [box-tob-analysis.md](./box-tob-analysis.md) |
|
||||||
|
|
||||||
|
The "Phase 2 deferred" note in §10 is **out of date** — MCP unification went in on
|
||||||
|
the same line. Pipeline-scoped (not user-scoped) MCP container is the realized
|
||||||
|
behavior: each pipeline's MCP servers share one `mcp-<pipeline>` session, and
|
||||||
|
user exec sessions use the template-derived id.
|
||||||
|
|
||||||
|
The remaining open work is multi-tenant overlays (tenant_id in session_id,
|
||||||
|
quota counters keyed by tenant), tracked in the toB analysis doc rather than here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problems
|
||||||
|
|
||||||
|
### 1.1 Default exec: per-message containers
|
||||||
|
|
||||||
|
Currently, `BoxService.execute_tool()` sets `session_id = str(query.query_id)` — an
|
||||||
|
auto-incrementing integer per incoming message. Every user message creates a new sandbox
|
||||||
|
container. Dependencies installed and in-container state are lost between messages.
|
||||||
|
|
||||||
|
### 1.2 Three isolated container pools
|
||||||
|
|
||||||
|
Default exec, skills, and MCP servers each manage their own containers with
|
||||||
|
independent session IDs:
|
||||||
|
|
||||||
|
| Path | Session ID | Container |
|
||||||
|
|--------------|-----------------------------------------------|-------------|
|
||||||
|
| Default exec | `str(query_id)` (per message) | Ephemeral |
|
||||||
|
| Skill exec | `skill-{launcher}_{id}-{skill_name}` | Per skill |
|
||||||
|
| MCP stdio | `mcp-{server_uuid}` | Per server |
|
||||||
|
|
||||||
|
This means a single logical user interaction can spawn 3+ containers that cannot
|
||||||
|
share state, see each other's files, or reuse installed dependencies.
|
||||||
|
|
||||||
|
### 1.3 Single bind mount limitation
|
||||||
|
|
||||||
|
`BoxSpec` currently supports only **one** `host_path` → `mount_path` bind mount.
|
||||||
|
This prevents mounting both a default workspace and skill directories into the
|
||||||
|
same container.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Concept Model
|
||||||
|
|
||||||
|
```
|
||||||
|
Platform Message
|
||||||
|
→ Query (query_id: int, auto-increment, per message)
|
||||||
|
→ Session (launcher_type + launcher_id, per chat window)
|
||||||
|
→ Conversation (uuid, per dialogue context within a Session)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Concept | Key | Example | Scope |
|
||||||
|
|---------------|-------------------------------------|----------------------------|------------------------------|
|
||||||
|
| Query | `query_id` | `42` | Single message |
|
||||||
|
| Session | `launcher_type` + `launcher_id` | `group_123456` | Chat window (group or PM) |
|
||||||
|
| Conversation | `conversation_id` (UUID) | `a1b2c3d4-...` | Dialogue context within a Session |
|
||||||
|
| Sender | `sender_id` | `789` | Individual user |
|
||||||
|
|
||||||
|
Note: in a **group chat**, all users share the same Session (keyed by `group_id`). The
|
||||||
|
individual sender is tracked as `sender_id` but does not affect Session/Conversation routing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Target Scenarios
|
||||||
|
|
||||||
|
| # | Scenario | Box Granularity | Desired `session_id` |
|
||||||
|
|----|--------------------------------|------------------------------------------|---------------------------------------------------------|
|
||||||
|
| 1 | Personal assistant | 1 Box per user, long-lived | `{launcher_type}_{launcher_id}` |
|
||||||
|
| 2 | Customer service | 1 Box per customer, cross-pipeline | `{launcher_type}_{launcher_id}` |
|
||||||
|
| 3 | Internal employee tool | 1 Box per employee | `{launcher_type}_{launcher_id}` |
|
||||||
|
| 4 | Group chat shared assistant | 1 Box per group | `{launcher_type}_{launcher_id}` |
|
||||||
|
| 5 | Group chat isolated per user | 1 Box per user within a group | `{launcher_type}_{launcher_id}_{sender_id}` |
|
||||||
|
| 6 | Teaching (cross-channel) | 1 Box per student across groups/PMs | `{sender_id}` |
|
||||||
|
| 7 | One-off execution | 1 Box per message (current behavior) | `{query_id}` |
|
||||||
|
| 8 | Multi-project development | 1 Box per conversation context | `{launcher_type}_{launcher_id}_{conversation_id}` |
|
||||||
|
|
||||||
|
No single fixed granularity covers all scenarios. A template-based approach is needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Design Overview
|
||||||
|
|
||||||
|
Two key changes:
|
||||||
|
|
||||||
|
1. **Unified container**: exec, skills, and MCP all share the same container per
|
||||||
|
session scope. No more separate container pools.
|
||||||
|
2. **Configurable session scope**: `session_id` is generated from a template with
|
||||||
|
pipeline variables, configurable per pipeline.
|
||||||
|
|
||||||
|
### 4.1 Unified Container with Multiple Mounts
|
||||||
|
|
||||||
|
A single container per session scope is created on first use. It has:
|
||||||
|
|
||||||
|
- **Primary mount**: default workspace at `/workspace` (from `default_host_workspace`)
|
||||||
|
- **Skill mounts**: each pipeline-bound skill's `package_root` mounted at
|
||||||
|
`/workspace/.skills/{skill_name}/`
|
||||||
|
- **MCP servers**: run as managed processes inside the same container
|
||||||
|
|
||||||
|
```
|
||||||
|
Container (session_id = "group_123456")
|
||||||
|
/workspace/ ← default workspace (bind mount, rw)
|
||||||
|
/workspace/.skills/web-search/ ← skill package (bind mount, rw)
|
||||||
|
/workspace/.skills/data-analysis/ ← skill package (bind mount, rw)
|
||||||
|
[managed process: mcp-server-a] ← MCP server running inside
|
||||||
|
[managed process: mcp-server-b] ← MCP server running inside
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires extending `BoxSpec` to support multiple mounts (see §5).
|
||||||
|
|
||||||
|
### 4.2 Session ID Template
|
||||||
|
|
||||||
|
A new field `box-session-id-template` in the `local-agent` pipeline runner config
|
||||||
|
controls the session scope:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# templates/metadata/pipeline/ai.yaml (under local-agent.config)
|
||||||
|
- name: box-session-id-template
|
||||||
|
label:
|
||||||
|
en_US: Sandbox Scope
|
||||||
|
zh_Hans: 沙箱作用域
|
||||||
|
description:
|
||||||
|
en_US: >-
|
||||||
|
Determines how sandbox environments are shared. Use variables to
|
||||||
|
control isolation granularity.
|
||||||
|
zh_Hans: >-
|
||||||
|
决定沙箱环境的共享方式。使用变量控制隔离粒度。
|
||||||
|
type: select
|
||||||
|
required: false
|
||||||
|
default: "{launcher_type}_{launcher_id}"
|
||||||
|
options:
|
||||||
|
- value: "{launcher_type}_{launcher_id}"
|
||||||
|
label:
|
||||||
|
en_US: Per chat (Recommended)
|
||||||
|
zh_Hans: 每个会话(推荐)
|
||||||
|
- value: "{launcher_type}_{launcher_id}_{sender_id}"
|
||||||
|
label:
|
||||||
|
en_US: Per user in chat
|
||||||
|
zh_Hans: 会话中每个用户
|
||||||
|
- value: "{launcher_type}_{launcher_id}_{conversation_id}"
|
||||||
|
label:
|
||||||
|
en_US: Per conversation context
|
||||||
|
zh_Hans: 每个对话上下文
|
||||||
|
- value: "{query_id}"
|
||||||
|
label:
|
||||||
|
en_US: Per message (isolated)
|
||||||
|
zh_Hans: 每条消息(完全隔离)
|
||||||
|
```
|
||||||
|
|
||||||
|
Available template variables (populated by PreProcessor in `query.variables`):
|
||||||
|
|
||||||
|
| Variable | Source | Example |
|
||||||
|
|---------------------|---------------------------------|----------------------|
|
||||||
|
| `{launcher_type}` | `query.session.launcher_type` | `person` / `group` |
|
||||||
|
| `{launcher_id}` | `query.session.launcher_id` | `123456` |
|
||||||
|
| `{sender_id}` | `query.sender_id` | `789` |
|
||||||
|
| `{conversation_id}` | `conversation.uuid` | `a1b2c3d4-...` |
|
||||||
|
| `{query_id}` | `query.query_id` | `42` |
|
||||||
|
|
||||||
|
Default `{launcher_type}_{launcher_id}` covers scenarios 1–4 out of the box.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. SDK Changes: Multi-Mount BoxSpec
|
||||||
|
|
||||||
|
### 5.1 Model Extension
|
||||||
|
|
||||||
|
```python
|
||||||
|
# box/models.py
|
||||||
|
|
||||||
|
class BoxMountSpec(pydantic.BaseModel):
|
||||||
|
"""A single bind mount specification."""
|
||||||
|
host_path: str
|
||||||
|
mount_path: str
|
||||||
|
mode: BoxHostMountMode = BoxHostMountMode.READ_WRITE
|
||||||
|
|
||||||
|
class BoxSpec(pydantic.BaseModel):
|
||||||
|
# ... existing fields ...
|
||||||
|
host_path: str | None = None # Primary mount (backward compat)
|
||||||
|
host_path_mode: BoxHostMountMode = BoxHostMountMode.READ_WRITE
|
||||||
|
mount_path: str = DEFAULT_BOX_MOUNT_PATH
|
||||||
|
extra_mounts: list[BoxMountSpec] = [] # NEW: additional mounts
|
||||||
|
```
|
||||||
|
|
||||||
|
`extra_mounts` is additive — the existing `host_path` / `mount_path` pair remains
|
||||||
|
the primary mount for backward compatibility.
|
||||||
|
|
||||||
|
### 5.2 Backend: Apply Extra Mounts
|
||||||
|
|
||||||
|
```python
|
||||||
|
# box/backend.py — CLISandboxBackend.start_session()
|
||||||
|
|
||||||
|
# Primary mount (unchanged)
|
||||||
|
if spec.host_path is not None and spec.host_path_mode != BoxHostMountMode.NONE:
|
||||||
|
args.extend(['-v', f'{spec.host_path}:{spec.mount_path}:{spec.host_path_mode.value}'])
|
||||||
|
|
||||||
|
# Extra mounts (NEW)
|
||||||
|
for mount in spec.extra_mounts:
|
||||||
|
if mount.mode != BoxHostMountMode.NONE:
|
||||||
|
args.extend(['-v', f'{mount.host_path}:{mount.mount_path}:{mount.mode.value}'])
|
||||||
|
```
|
||||||
|
|
||||||
|
Same pattern for nsjail backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. LangBot Changes
|
||||||
|
|
||||||
|
### 6.1 Session ID Resolution
|
||||||
|
|
||||||
|
In `BoxService.execute_tool()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Before:
|
||||||
|
spec_payload.setdefault('session_id', str(query.query_id))
|
||||||
|
|
||||||
|
# After:
|
||||||
|
template = (query.pipeline_config or {}).get('ai', {}) \
|
||||||
|
.get('local-agent', {}).get('box-session-id-template',
|
||||||
|
'{launcher_type}_{launcher_id}')
|
||||||
|
variables = query.variables or {}
|
||||||
|
session_id = template.format_map(collections.defaultdict(
|
||||||
|
lambda: 'unknown', variables
|
||||||
|
))
|
||||||
|
spec_payload.setdefault('session_id', session_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Skill Exec: Use Same Container
|
||||||
|
|
||||||
|
Currently `native.py:_invoke_exec` creates a separate `BoxWorkspaceSession` per
|
||||||
|
skill with `host_path=package_root`. Instead:
|
||||||
|
|
||||||
|
1. Use the **same session_id** as default exec (from the template).
|
||||||
|
2. Pass the skill's `package_root` as an **extra mount** at
|
||||||
|
`/workspace/.skills/{skill_name}/` instead of replacing `/workspace`.
|
||||||
|
3. The container already has the default workspace at `/workspace`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# native.py — _invoke_exec, skill branch (REVISED)
|
||||||
|
|
||||||
|
# Same session_id as default exec
|
||||||
|
session_id = resolve_box_session_id(query)
|
||||||
|
|
||||||
|
spec_payload = {
|
||||||
|
'cmd': rewritten_command,
|
||||||
|
'workdir': rewritten_workdir,
|
||||||
|
'session_id': session_id,
|
||||||
|
'extra_mounts': [{
|
||||||
|
'host_path': package_root,
|
||||||
|
'mount_path': f'/workspace/.skills/{selected_skill_name}',
|
||||||
|
'mode': 'rw',
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
result = await self.ap.box_service.execute_spec_payload(spec_payload, query)
|
||||||
|
```
|
||||||
|
|
||||||
|
The virtual path `/workspace/.skills/{name}` no longer needs rewriting at the
|
||||||
|
command level — it maps directly to the bind mount path inside the container.
|
||||||
|
|
||||||
|
### 6.3 MCP: Use Same Container
|
||||||
|
|
||||||
|
MCP servers should run inside the same container as exec and skills. Changes:
|
||||||
|
|
||||||
|
1. `BoxStdioSessionRuntime` uses the pipeline's session_id template instead of
|
||||||
|
`mcp-{server_uuid}`.
|
||||||
|
2. MCP server's working directory is a subdirectory (e.g. `/workspace/.mcp/{name}/`).
|
||||||
|
3. MCP server's dependencies are mounted or installed into that subdirectory.
|
||||||
|
4. The MCP server runs as a managed process inside the shared container.
|
||||||
|
|
||||||
|
Since MCP servers start at LangBot boot (not per-query), the session must be
|
||||||
|
created eagerly. The container will be kept alive by the managed process
|
||||||
|
exemption in TTL reaping (`runtime.py:259`).
|
||||||
|
|
||||||
|
**Note**: MCP sessions are pipeline-scoped (not per-launcher), so their session_id
|
||||||
|
should be a **fixed identifier per pipeline** rather than the user-facing template.
|
||||||
|
This means one shared MCP container per pipeline, with user exec sessions separate.
|
||||||
|
|
||||||
|
Alternatively, in a future iteration, MCP managed processes could be launched
|
||||||
|
lazily into the user's container on first MCP tool call. This is more complex
|
||||||
|
but maximizes sharing. For V1, keeping MCP containers at pipeline scope is
|
||||||
|
simpler and more predictable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Mount Layout Summary
|
||||||
|
|
||||||
|
### Default exec (no skills activated)
|
||||||
|
|
||||||
|
```
|
||||||
|
Container (session_id from template)
|
||||||
|
/workspace/ ← default_host_workspace (rw)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exec with activated skills
|
||||||
|
|
||||||
|
```
|
||||||
|
Container (same session_id)
|
||||||
|
/workspace/ ← default_host_workspace (rw)
|
||||||
|
/workspace/.skills/web-search/ ← skill package_root (rw)
|
||||||
|
/workspace/.skills/data-analysis/ ← skill package_root (rw)
|
||||||
|
```
|
||||||
|
|
||||||
|
Extra mounts are **additive** — they are added when the container is first
|
||||||
|
created (or on the first exec that references a skill). Since Docker bind
|
||||||
|
mounts are specified at container creation time, skills must be known at
|
||||||
|
creation time.
|
||||||
|
|
||||||
|
**Resolution**: When creating a container, inject `extra_mounts` for **all
|
||||||
|
pipeline-bound skills** (from `extensions_preferences`), not just the
|
||||||
|
currently activated one. This way any skill can be activated later without
|
||||||
|
recreating the container.
|
||||||
|
|
||||||
|
### MCP servers (V1: pipeline-scoped)
|
||||||
|
|
||||||
|
```
|
||||||
|
Container (session_id = "mcp-pipeline-{pipeline_uuid}")
|
||||||
|
/workspace/ ← MCP shared workspace
|
||||||
|
/workspace/.mcp/server-a/ ← MCP server A files
|
||||||
|
/workspace/.mcp/server-b/ ← MCP server B files
|
||||||
|
[managed process: server-a]
|
||||||
|
[managed process: server-b]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Data Migration
|
||||||
|
|
||||||
|
Existing pipelines do not have `box-session-id-template`. The backend uses
|
||||||
|
`.get(..., default)` so missing keys fall back to `{launcher_type}_{launcher_id}`.
|
||||||
|
This changes behavior from per-message to per-launcher for existing pipelines.
|
||||||
|
|
||||||
|
Recommendation: **accept the behavior change** — per-launcher is the more
|
||||||
|
intuitive default, and the old per-message behavior was rarely desired.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Cloud Quota Implications
|
||||||
|
|
||||||
|
| Scope | Typical concurrent containers |
|
||||||
|
|-----------------------------------------------|-------------------------------|
|
||||||
|
| `{query_id}` (per message) | Many, short-lived |
|
||||||
|
| `{launcher_type}_{launcher_id}` (per chat) | = active chat count |
|
||||||
|
| `{sender_id}` (per user) | = active user count |
|
||||||
|
| `{conversation_id}` (per conversation) | Between per-chat and per-msg |
|
||||||
|
|
||||||
|
With the unified container model, each scope value maps to exactly **one**
|
||||||
|
container (instead of potentially 3+ per-message). This significantly reduces
|
||||||
|
resource usage.
|
||||||
|
|
||||||
|
Quota enforcement point: `BoxRuntime._get_or_create_session()` in the SDK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Session scope + skill unification (this PR)
|
||||||
|
|
||||||
|
1. **SDK**: Extend `BoxSpec` with `extra_mounts: list[BoxMountSpec]`.
|
||||||
|
2. **SDK**: Update Docker/nsjail backends to apply extra mounts.
|
||||||
|
3. **LangBot**: Add `box-session-id-template` to `local-agent` YAML metadata
|
||||||
|
and default pipeline config JSON.
|
||||||
|
4. **LangBot**: Update `BoxService.execute_tool()` to use template interpolation.
|
||||||
|
5. **LangBot**: Update `native.py:_invoke_exec` skill branch to use same
|
||||||
|
session_id + extra mounts instead of separate `BoxWorkspaceSession`.
|
||||||
|
6. **LangBot**: On container creation, inject extra mounts for all
|
||||||
|
pipeline-bound skills.
|
||||||
|
7. **Frontend**: No code change — `DynamicFormComponent` renders `select` fields.
|
||||||
|
8. **Tests**: Unit tests for template interpolation and multi-mount specs.
|
||||||
|
|
||||||
|
### Phase 2: MCP unification (future)
|
||||||
|
|
||||||
|
1. Refactor `BoxStdioSessionRuntime` to use pipeline-scoped shared container.
|
||||||
|
2. MCP servers become managed processes in the shared container.
|
||||||
|
3. Support multiple concurrent managed processes per container.
|
||||||
|
|
||||||
|
MCP unification is deferred because it requires changes to the managed process
|
||||||
|
model (currently 1 managed process per session) and has startup ordering
|
||||||
|
concerns (MCP servers start at boot, before any user query determines
|
||||||
|
a session_id).
|
||||||
122
docs/review/box-test-coverage.md
Normal file
122
docs/review/box-test-coverage.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Box 系统测试覆盖分析
|
||||||
|
|
||||||
|
> 更新日期: 2026-06-02
|
||||||
|
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
||||||
|
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 测试文件清单
|
||||||
|
|
||||||
|
### LangBot 仓库
|
||||||
|
|
||||||
|
| 文件 | 行数 | CI 运行 | 覆盖范围 |
|
||||||
|
|------|------|---------|---------|
|
||||||
|
| `tests/unit_tests/box/test_box_connector.py` | 106 | 是 | Connector 传输决策、WS relay URL、dispose、心跳/重连 |
|
||||||
|
| `tests/unit_tests/box/test_box_service.py` | 1224 | 是 | Service 核心逻辑(最全面) |
|
||||||
|
| `tests/unit_tests/box/test_workspace.py` | 147 | 是 | WorkspaceSession 路径重写、payload 构建 |
|
||||||
|
| `tests/unit_tests/provider/test_mcp_box_integration.py` | 707 | 是 | MCP Box 配置、路径重写、payload、shared-session/multi-process、runtime info |
|
||||||
|
| `tests/unit_tests/provider/test_localagent_sandbox_exec.py` | 444 | 是 | LocalAgent exec 流程、流式、Skill 激活 (Tool Call) |
|
||||||
|
| `tests/unit_tests/provider/test_tool_manager_native.py` | 249 | 是 | ToolManager 路由、native tool CRUD、路径穿越、6 工具暴露 |
|
||||||
|
| `tests/unit_tests/provider/test_skill_tools.py` | 582 | 是 | Skill 管理、Tool Call 激活、路径、authoring CRUD |
|
||||||
|
| `tests/unit_tests/test_skill_service.py` | 396 | 是 | HTTP service:skill CRUD、zip/GitHub install、文件浏览 |
|
||||||
|
| `tests/unit_tests/test_paths.py` | 23 | 是 | paths 工具 |
|
||||||
|
| `tests/unit_tests/test_preproc.py` | 134 | 是 | PreProcessor 注入 session 变量、bound skill 解析 |
|
||||||
|
| `tests/unit_tests/pipeline/test_chat_handler_logging.py` | 78 | 是 | Chat handler 日志相关回归 |
|
||||||
|
| `tests/integration_tests/box/test_box_integration.py` | 329 | **否** | 真实容器执行、超时、网络隔离 |
|
||||||
|
| `tests/integration_tests/box/test_box_mcp_integration.py` | 368 | **否** | Managed process、WS attach、shared-session 清理 |
|
||||||
|
|
||||||
|
### SDK 仓库
|
||||||
|
|
||||||
|
| 文件 | 行数 | CI 运行 | 覆盖范围 |
|
||||||
|
|------|------|---------|---------|
|
||||||
|
| `tests/box/test_backend_selection.py` | 255 | 是 | 显式 backend / local 模式探测顺序 / 配置变更触发 reselect |
|
||||||
|
| `tests/box/test_nsjail_backend.py` | 452 | 是 | nsjail 可用性、安装版 CLI vs 容器内 CLI、session、arg 构建、资源限制 |
|
||||||
|
| `tests/box/test_e2b_backend.py` | 482 | 是 | E2B SDK mock、session 生命周期、extra_mounts 同步 |
|
||||||
|
| `tests/box/test_skill_store.py` | 88 | 是 | zip preview/install、基础 file CRUD |
|
||||||
|
|
||||||
|
**总计**: 17 个测试文件, ~6,500 行测试代码; 其中 2 个集成测试(约 700 行)在 CI 中不运行。
|
||||||
|
|
||||||
|
> 较 2026-04-16 版增加:`test_skill_service.py`、`test_paths.py`、`test_preproc.py`、`test_chat_handler_logging.py` (LangBot),`test_backend_selection.py`、`test_e2b_backend.py`、`test_skill_store.py` (SDK)。`test_nsjail_backend.py` 增加 CLI 兼容性 case (commit `feed530`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 覆盖良好的区域
|
||||||
|
|
||||||
|
| 区域 | 质量 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| BoxRuntime session 管理 | 优秀 | session 复用、冲突检测、TTL 配置、消失 session 重建 |
|
||||||
|
| BoxService Profile 系统 | 优秀 | 4 个内置 Profile、locked/unlocked 字段、timeout clamp |
|
||||||
|
| BoxService host mount 安全 | 优秀 | allowed_mount_roots、disallowed_roots、shared host root |
|
||||||
|
| BoxService workspace quota | 优秀 | 前置/后置配额检查、超额清理 |
|
||||||
|
| BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr |
|
||||||
|
| BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 |
|
||||||
|
| BoxService session 模板 | 良好 | `resolve_box_session_id` + `build_skill_extra_mounts` 在 service / native / mcp 三处都有覆盖 |
|
||||||
|
| RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error |
|
||||||
|
| BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、心跳与重连回调 |
|
||||||
|
| BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写、stage host file |
|
||||||
|
| BoxHostMountMode.NONE | 良好 | 枚举校验、workdir 约束 |
|
||||||
|
| NsjailBackend | 良好 | 可用性、安装版 vs 容器内、session 生命周期、arg 构建、资源限制 |
|
||||||
|
| E2BBackend | 良好 | mock SDK、session/extra_mounts 同步 |
|
||||||
|
| Backend selection | 良好 | 显式 backend 优先级、local 探测顺序、配置变更触发 reselect |
|
||||||
|
| MCP Box 集成 | 良好 | config model、路径重写、payload、shared-session 多 process |
|
||||||
|
| Native tool loader | 良好 | 6 工具(exec/read/write/edit/glob/grep)、路径穿越拦截 |
|
||||||
|
| LocalAgent exec 流程 | 良好 | 完整 tool call 循环、流式、system prompt 注入、Tool Call 激活 |
|
||||||
|
| Skill 系统 | 良好 | 加载、Tool Call 激活、marker、路径解析、authoring CRUD、HTTP service |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 覆盖缺失的区域
|
||||||
|
|
||||||
|
### 3.1 零测试 / 严重不足
|
||||||
|
|
||||||
|
| 区域 | 源文件 | 影响 |
|
||||||
|
|------|--------|------|
|
||||||
|
| **`security.py`** | SDK `box/security.py` (52 行) | `validate_sandbox_security()` 无任何测试。阻止 `/etc`/`/proc`/Docker socket 等危险挂载的安全函数从未被验证 |
|
||||||
|
| **`policy.py`** | `pkg/box/policy.py` (98 行) | 三层安全策略无测试(也是死代码) |
|
||||||
|
| **`skill_store.py` 边缘场景** | SDK `box/skill_store.py` (647 行) vs 测试 88 行 | GitHub 安装路径、`source_subdir` / `target_suffix` 组合、损坏 zip、文件冲突等场景未覆盖 |
|
||||||
|
|
||||||
|
### 3.2 未测试的关键路径
|
||||||
|
|
||||||
|
| 区域 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **Session TTL 过期** | 测试配置了 `session_ttl_sec` 但从未推进时间验证过期清理 |
|
||||||
|
| **并发 session 访问** | 无并发 exec / 并发创建 / race condition 测试 |
|
||||||
|
| **Container backend (Docker)** | 仅通过集成测试覆盖(CI 不运行),单元测试全用 FakeBackend |
|
||||||
|
| **E2B 真实 sandbox** | 单测全是 mock,未对接真实 E2B API |
|
||||||
|
| **BoxRuntime shutdown()** | 在 test cleanup 中调用但未验证行为 |
|
||||||
|
| **BoxServerHandler 错误路径** | 畸形请求、未知 action 类型 |
|
||||||
|
| **WS relay** | 仅在集成测试中覆盖(CI 不运行) |
|
||||||
|
| **NsjailBackend managed process** | 完全未测试 |
|
||||||
|
| **MCP stdio 完整生命周期** | 依赖安装 → 进程启动 → 健康检查 → 多 process 并发 → 重试 |
|
||||||
|
| **BoxService start/stop_managed_process** | 单 process 流转有单测,多 process 互不阻塞主要靠集成测试 |
|
||||||
|
| **重连指数退避** | connector 单测覆盖回调接线,未实际跑完整重连周期 |
|
||||||
|
|
||||||
|
### 3.3 边缘情况缺失
|
||||||
|
|
||||||
|
| 区域 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| BoxSpec 校验 | 无效 session_id 格式、超长命令、env 特殊字符 |
|
||||||
|
| BoxSpec.extra_mounts | 重复 mount_path、与 host_path 冲突、绝对 vs 相对路径 |
|
||||||
|
| BoxExecutionResult | 仅 COMPLETED 和 TIMED_OUT,无 ERROR 状态测试 |
|
||||||
|
| 多后端 fallback | local 模式探测顺序仅靠 mock,无真实 Docker 不可用 → nsjail 真机 fallback 测试 |
|
||||||
|
| Profile YAML 加载 | 测试用硬编码字符串,未从真实 config.yaml 加载 |
|
||||||
|
| INIT 配置变更触发 backend 重建 | 单测仅在初始化场景验证 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 集成测试 vs CI 的差距
|
||||||
|
|
||||||
|
CI 仅运行 `tests/unit_tests/`,以下场景**从未在自动化中验证**:
|
||||||
|
|
||||||
|
- 真实容器的创建/执行/销毁
|
||||||
|
- 容器网络隔离(`--network none`)
|
||||||
|
- 容器资源限制生效(cpus/memory/pids_limit)
|
||||||
|
- Managed process 的 WS 双向 I/O
|
||||||
|
- 多 process 同 session 并发 I/O
|
||||||
|
- 孤儿容器清理
|
||||||
|
- Session 删除清理容器
|
||||||
|
- 进程退出检测
|
||||||
|
- E2B 真实 sandbox 行为
|
||||||
|
|
||||||
|
**建议**: 在 CI 中加一个可选的 Docker-in-Docker 集成测试 stage,至少覆盖核心执行路径(exec / MCP attach / session 销毁)。
|
||||||
167
docs/review/box-tob-analysis.md
Normal file
167
docs/review/box-tob-analysis.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Box 系统 toB 商业化分析
|
||||||
|
|
||||||
|
> 更新日期: 2026-06-02
|
||||||
|
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
||||||
|
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 现有优势
|
||||||
|
|
||||||
|
| 能力 | toB 价值 | 代码位置 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| **沙箱隔离执行** | 企业安全运行不受信代码的基础能力 | SDK `box/backend.py` |
|
||||||
|
| **多后端支持** | 适配不同企业容器基础设施 (Podman/Docker/nsjail/E2B) | SDK `box/runtime.py` `_select_backend()` |
|
||||||
|
| **E2B 云沙箱** | SaaS / 无 Docker 部署的兜底执行环境 | SDK `box/e2b_backend.py` |
|
||||||
|
| **连接自愈** | 心跳 + 自动重连,单点 Box runtime 故障可恢复 | `pkg/box/connector.py` `_heartbeat_loop`, `pkg/box/service.py` `_reconnect_loop` |
|
||||||
|
| **Profile + locked 字段** | 运维锁定安全边界,LLM/用户无法绕过 | `pkg/box/service.py`, SDK `box/models.py` |
|
||||||
|
| **资源限制** | CPU/内存/PID 数限制防止资源滥用 | SDK `backend.py` `--cpus/--memory/--pids-limit` |
|
||||||
|
| **Workspace quota** | 磁盘用量控制 | `pkg/box/service.py` `_enforce_workspace_quota` |
|
||||||
|
| **静默降级** | Box 不可用不影响其他功能,降低部署门槛 | `pkg/box/service.py:78` `_available=False` |
|
||||||
|
| **孤儿容器清理** | 防止泄漏的容器持续占用资源 | SDK `backend.py` `cleanup_orphaned_containers` |
|
||||||
|
| **网络隔离** | `--network none` 防止数据外泄 | SDK `backend.py` start_session |
|
||||||
|
| **只读根文件系统** | `--read-only` 防止容器被持久篡改 | SDK `backend.py` start_session |
|
||||||
|
| **Host path 白名单** | `allowed_host_mount_roots` 限制可挂载目录 | `pkg/box/service.py` `_validate_host_mount` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. toB 差距分析
|
||||||
|
|
||||||
|
### 2.1 安全与合规
|
||||||
|
|
||||||
|
| 维度 | 现状 | toB 要求 | 优先级 |
|
||||||
|
|------|------|---------|--------|
|
||||||
|
| **WS relay 认证** | 无认证,任何人可 attach | 至少 token 认证 | **P0** |
|
||||||
|
| **安全策略** | policy.py 是死代码,实际无细粒度控制 | 工具级 allow/deny、沙箱模式控制 | **P0** |
|
||||||
|
| **审计日志** | 仅内存中 50 条 `_recent_errors` | 持久化审计:谁何时执行了什么、结果如何 | **P0** |
|
||||||
|
| **Host path 校验** | 黑名单策略,`/` 未拦截 | 白名单策略,默认拒绝 | **P1** |
|
||||||
|
| **数据驻留** | 无控制 | GDPR / 等保要求的数据隔离 | **P2** |
|
||||||
|
|
||||||
|
### 2.2 多租户
|
||||||
|
|
||||||
|
| 维度 | 现状 | toB 要求 | 优先级 |
|
||||||
|
|------|------|---------|--------|
|
||||||
|
| **租户隔离** | 无租户概念 | BoxSpec/Profile 绑定 tenant_id | **P0** |
|
||||||
|
| **RBAC** | 仅 token 认证 | admin/operator/viewer 角色权限 | **P0** |
|
||||||
|
| **资源配额** | 单一 workspace quota | 每租户 CPU 时间/内存/并发/执行次数配额 | **P1** |
|
||||||
|
| **Session 隔离** | 所有 session 共享 dict | 按租户分区,互不可见 | **P1** |
|
||||||
|
|
||||||
|
### 2.3 可靠性
|
||||||
|
|
||||||
|
| 维度 | 现状 | toB 要求 | 优先级 |
|
||||||
|
|------|------|---------|--------|
|
||||||
|
| **连接恢复** | 已实现:20s 心跳 + `_reconnect_loop` 指数退避 | 已满足基本要求 | 已有 |
|
||||||
|
| **Session 清理** | 机会性(仅新建时触发) | 定时清理 + 独立 reaper | **P1** |
|
||||||
|
| **水平扩展** | 单 Box Runtime 实例 | 多实例负载均衡(按 tenant 路由) | **P1** |
|
||||||
|
| **优雅降级** | 已有(_available=False) | 已满足基本要求 | 已有 |
|
||||||
|
| **Backend 自愈** | 已实现:`get_status` 时若 backend 不可用会重新选择 | 已满足基本要求 | 已有 |
|
||||||
|
|
||||||
|
### 2.4 可观测性
|
||||||
|
|
||||||
|
| 维度 | 现状 | toB 要求 | 优先级 |
|
||||||
|
|------|------|---------|--------|
|
||||||
|
| **监控指标** | 无 Prometheus metrics | session 数/执行延迟/资源用量/错误率 | **P1** |
|
||||||
|
| **结构化日志** | Python logging, 无结构化 | JSON 格式日志,含 trace_id/tenant_id | **P1** |
|
||||||
|
| **前端面板** | 监控页接入 `/api/v1/box/status`(backend 名 + 活跃 session 数);`sessions` / `errors` 仍未接入 | 完整状态面板 + 历史错误/审计列表 | **P2** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. SaaS 部署架构建议
|
||||||
|
|
||||||
|
### 3.1 方案 A: 共享 Box Runtime Pool (快速上线)
|
||||||
|
|
||||||
|
```
|
||||||
|
LangBot Instance ──> Box Runtime (共享)
|
||||||
|
├─ tenant_id 标签隔离
|
||||||
|
├─ Redis 配额计数器
|
||||||
|
└─ Container labels: langbot.tenant_id=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
- **优点**: 改动最小,加 tenant_id 到 BoxSpec/labels 即可
|
||||||
|
- **缺点**: 容器引擎共享,安全隔离弱
|
||||||
|
|
||||||
|
### 3.2 方案 B: 每租户 K8s Namespace + gVisor (推荐中期)
|
||||||
|
|
||||||
|
```
|
||||||
|
LangBot ──> K8s API
|
||||||
|
├─ namespace: tenant-xxx
|
||||||
|
│ ├─ RuntimeClass: gVisor (runsc)
|
||||||
|
│ ├─ ResourceQuota
|
||||||
|
│ └─ NetworkPolicy
|
||||||
|
└─ namespace: tenant-yyy
|
||||||
|
└─ ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- **优点**: 强隔离(namespace + gVisor),原生 K8s 配额
|
||||||
|
- **缺点**: 需要重写 backend 为 K8s Job,部署复杂度高
|
||||||
|
|
||||||
|
### 3.3 方案 C: K8s Job 直接编排 (长期)
|
||||||
|
|
||||||
|
```
|
||||||
|
LangBot ──> K8s Job per execution
|
||||||
|
├─ 每次执行创建 Job
|
||||||
|
├─ Pod Security Standards
|
||||||
|
├─ 自动调度和资源分配
|
||||||
|
└─ Job TTL Controller 自动清理
|
||||||
|
```
|
||||||
|
|
||||||
|
- **优点**: 最强隔离,天然水平扩展
|
||||||
|
- **缺点**: 冷启动延迟,架构重写
|
||||||
|
|
||||||
|
**推荐演进路径**: A → B → C
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 配额体系建议
|
||||||
|
|
||||||
|
### 三层配额
|
||||||
|
|
||||||
|
| 层 | 实现 | 作用 |
|
||||||
|
|----|------|------|
|
||||||
|
| **内核层** | Docker `--cpus`/`--memory`/`--storage-opt` | 硬性资源上限,不可绕过 |
|
||||||
|
| **应用层** | Redis 原子计数器 | 并发 session 数/执行次数/CPU 时间预算 |
|
||||||
|
| **计费层** | 月度聚合 | 按租户计费(session-hours/execution-count) |
|
||||||
|
|
||||||
|
### Profile 与套餐映射
|
||||||
|
|
||||||
|
| 套餐 | Profile | locked 字段 | 配额 |
|
||||||
|
|------|---------|------------|------|
|
||||||
|
| Free | `offline_readonly` | network, host_path_mode, rootfs | 10 exec/天, 0.5 CPU, 256MB |
|
||||||
|
| Pro | `default` | (无) | 100 exec/天, 1 CPU, 512MB |
|
||||||
|
| Enterprise | `network_extended` | (按需) | 无限, 2 CPU, 1GB, 自定义镜像 |
|
||||||
|
|
||||||
|
### TOCTOU 配额修复
|
||||||
|
|
||||||
|
当前 `_enforce_workspace_quota` 的 TOCTOU 问题可通过两种方式解决:
|
||||||
|
|
||||||
|
1. **预留式配额** (应用层): Redis `INCRBY` 预扣额度 → 执行 → 成功则扣减,失败则回滚
|
||||||
|
2. **内核级限制** (Docker): `--storage-opt size=500m` 直接限制容器可写层大小
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 优先实施路线
|
||||||
|
|
||||||
|
### Phase 1 (2-4 周): 安全基线
|
||||||
|
|
||||||
|
- [ ] WS relay 加 token 认证
|
||||||
|
- [ ] 接入或删除 policy.py
|
||||||
|
- [x] ~~Box 加重连和心跳~~(已完成,见 [box-issues.md 已解决](./box-issues.md))
|
||||||
|
- [ ] 审计日志持久化(至少写文件/数据库)
|
||||||
|
- [ ] `security.py` 加 `/` 拦截,考虑白名单
|
||||||
|
- [ ] INIT 与 backend 初始化顺序整理(避免 backend 在配置到达前实例化)
|
||||||
|
|
||||||
|
### Phase 2 (4-8 周): 多租户基础
|
||||||
|
|
||||||
|
- [ ] BoxSpec 加 `tenant_id` 字段
|
||||||
|
- [ ] 容器 labels 加 tenant 标识
|
||||||
|
- [ ] Redis 配额计数器(并发/执行次数/时间)
|
||||||
|
- [ ] RBAC 基础框架
|
||||||
|
- [ ] 定时 session reaper
|
||||||
|
|
||||||
|
### Phase 3 (8-16 周): 生产就绪
|
||||||
|
|
||||||
|
- [ ] Prometheus metrics exporter
|
||||||
|
- [ ] 前端 Box 状态面板
|
||||||
|
- [ ] K8s backend 支持 (方案 B)
|
||||||
|
- [ ] 结构化日志 (JSON, trace_id)
|
||||||
|
- [ ] 水平扩展支持
|
||||||
222
docs/review/box-vs-plugin-runtime.md
Normal file
222
docs/review/box-vs-plugin-runtime.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# Box Runtime vs Plugin Runtime: 连接架构对比
|
||||||
|
|
||||||
|
> 更新日期: 2026-06-02
|
||||||
|
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
||||||
|
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 总体差异
|
||||||
|
|
||||||
|
| 维度 | Plugin Runtime | Box Runtime |
|
||||||
|
|------|---------------|-------------|
|
||||||
|
| **继承关系** | `PluginRuntimeConnector(ManagedRuntimeConnector)` | `BoxRuntimeConnector`(独立类) |
|
||||||
|
| **传输分支** | 3 条 (Docker/WS, Win32/subprocess+WS, Unix/stdio) | 3 条 (本地 stdio, Win32/subprocess+WS, 远程 WS) |
|
||||||
|
| **心跳** | 20s ping loop | 20s ping loop(`_heartbeat_loop`) |
|
||||||
|
| **重连** | WS 模式: sleep 3s → re-initialize | 由 BoxService `_reconnect_loop` 处理,指数退避 |
|
||||||
|
| **Handler 类型** | `RuntimeConnectionHandler` (1132 行, 25+ action) | 基础 `Handler` + `BoxServerHandler`(SDK 端 25 action) |
|
||||||
|
| **Client 抽象** | Handler 即 API | 独立 `ActionRPCBoxClient` 封装 Handler |
|
||||||
|
| **启用/禁用** | `is_enable_plugin` 开关 | 无开关(可用/不可用由初始化结果决定) |
|
||||||
|
| **初始化失败** | 异常上抛 | 静默降级 `_available=False` |
|
||||||
|
| **Shutdown** | 直接杀进程 | RPC SHUTDOWN → 清理容器 → 再杀进程 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 传输决策
|
||||||
|
|
||||||
|
### Plugin: 3-路决策
|
||||||
|
|
||||||
|
```python
|
||||||
|
# pkg/plugin/connector.py:106-165
|
||||||
|
if get_platform() == 'docker' or use_websocket_to_connect_plugin_runtime():
|
||||||
|
# Docker/WS → ws://langbot_plugin_runtime:5400/control/ws
|
||||||
|
elif get_platform() == 'win32':
|
||||||
|
# Windows → 起子进程(无 pipe) + ws://localhost:5400/control/ws
|
||||||
|
else:
|
||||||
|
# Unix/Mac → StdioClientController(python -m langbot_plugin.cli rt -s)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Box: 3-路决策
|
||||||
|
|
||||||
|
```python
|
||||||
|
# pkg/box/connector.py
|
||||||
|
if self._uses_websocket():
|
||||||
|
if platform.get_platform() == 'win32' and not self.configured_runtime_url:
|
||||||
|
await self._start_subprocess_then_ws() # subprocess + ws://localhost:5410/rpc/ws
|
||||||
|
else:
|
||||||
|
await self._connect_remote_ws() # ws://{host}:5410/rpc/ws
|
||||||
|
else:
|
||||||
|
await self._start_local_stdio() # StdioClientController
|
||||||
|
```
|
||||||
|
|
||||||
|
> 历史:2026-04-16 版本本文档曾把 Box 描述为 2 路决策(缺 Windows 分支)。现已对齐 Plugin 的 3 路设计。
|
||||||
|
|
||||||
|
### 决策矩阵
|
||||||
|
|
||||||
|
| 环境 | Plugin | Box |
|
||||||
|
|------|--------|-----|
|
||||||
|
| Docker | WS → `:5400` | WS → `:5410/rpc/ws` |
|
||||||
|
| `--standalone-box` | N/A | WS → `localhost:5410/rpc/ws` |
|
||||||
|
| Windows 非 Docker | subprocess + WS (`:5400`) | subprocess + WS (`localhost:5410/rpc/ws`) |
|
||||||
|
| Unix/Mac 非 Docker | stdio | stdio |
|
||||||
|
| 手动配置 URL | 通过配置项 | WS → 用户配置的 URL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 连接建立
|
||||||
|
|
||||||
|
### 同步模式差异
|
||||||
|
|
||||||
|
**Plugin**: `new_connection_callback` 内直接 ping + await handler_task,`initialize()` 通过 `create_task()` 异步启动,不阻塞等待连接。
|
||||||
|
|
||||||
|
**Box**: 使用 `asyncio.Event` + `wait_for(timeout=30s)` 模式,`initialize()` 同步等待连接成功或超时。
|
||||||
|
|
||||||
|
### Box stdio 路径
|
||||||
|
|
||||||
|
```
|
||||||
|
connector._start_local_stdio()
|
||||||
|
├─ connected = asyncio.Event()
|
||||||
|
├─ ctrl = StdioClientController(python, ['-m', 'langbot_plugin.cli.__init__', 'box', '-s', '--ws-control-port', N])
|
||||||
|
├─ _ctrl_task = create_task(ctrl.run(callback))
|
||||||
|
│ callback:
|
||||||
|
│ handler = Handler(connection) ← 基础 Handler, 无 disconnect_callback
|
||||||
|
│ client.set_handler(handler)
|
||||||
|
│ _handler_task = create_task(handler.run())
|
||||||
|
│ call_action(PING, {}) ← 握手, timeout=15s
|
||||||
|
│ connected.set() ← 通知外层
|
||||||
|
│ await _handler_task ← 阻塞直到断开
|
||||||
|
└─ await wait_for(connected.wait(), 30s) ← 同步等待
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin stdio 路径
|
||||||
|
|
||||||
|
```
|
||||||
|
connector.initialize()
|
||||||
|
├─ ctrl = StdioClientController(python, ['-m', 'langbot_plugin.cli', 'rt', '-s'])
|
||||||
|
├─ task = ctrl.run(callback)
|
||||||
|
│ callback:
|
||||||
|
│ disconnect_callback:
|
||||||
|
│ [WS] → runtime_disconnect_callback → 重连
|
||||||
|
│ [stdio] → 仅日志, 不重连
|
||||||
|
│ handler = RuntimeConnectionHandler(conn, disconnect_cb, ap)
|
||||||
|
│ create_task(handler.run())
|
||||||
|
│ handler.ping() ← 握手, timeout=10s
|
||||||
|
│ await handler_task ← 阻塞直到断开
|
||||||
|
├─ create_task(heartbeat_loop()) ← 20s ping loop
|
||||||
|
└─ create_task(task) ← 不等待连接
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 心跳与重连
|
||||||
|
|
||||||
|
### 心跳
|
||||||
|
|
||||||
|
| 维度 | Plugin | Box |
|
||||||
|
|------|--------|-----|
|
||||||
|
| 有心跳? | 是 | 是(`connector.py` `_heartbeat_loop`) |
|
||||||
|
| 间隔 | 20s | 20s |
|
||||||
|
| 失败处理 | 仅 DEBUG 日志,不触发重连 | 仅 DEBUG 日志,依赖 connection close 触发重连 |
|
||||||
|
| 生命周期 | 整个应用生命周期 | 连接建立后启动;`dispose()` 时 cancel |
|
||||||
|
|
||||||
|
### 重连
|
||||||
|
|
||||||
|
| 维度 | Plugin | Box |
|
||||||
|
|------|--------|-----|
|
||||||
|
| Docker/WS 断开 | `runtime_disconnect_callback` → sleep 3s → re-initialize | `runtime_disconnect_callback` → `BoxService._reconnect_loop()`(指数退避) |
|
||||||
|
| WS 连接失败 | 同上 | 同上;初次失败时 `_available=False`,重连成功后恢复 |
|
||||||
|
| stdio 断开 | 仅日志,不重连 | 接同样回调;stdio 重连需重新 fork 子进程 |
|
||||||
|
| 重连退避 | 固定 3s,无 backoff | 指数退避 |
|
||||||
|
|
||||||
|
> 历史:2026-04-16 版本本文档曾把心跳与重连标记为 Box 缺失。这两项已在 commit `2dfd9d5d` / `c6882cf` / `5029d9c` 等修复(详见 [box-issues.md 已解决](./box-issues.md))。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 共享 IO 层
|
||||||
|
|
||||||
|
两者复用同一套 SDK IO 基础设施:
|
||||||
|
|
||||||
|
```
|
||||||
|
Handler ← ABC (runtime/io/handler.py)
|
||||||
|
├── RuntimeConnectionHandler (Plugin 用, LangBot 侧)
|
||||||
|
├── ControlConnectionHandler (Plugin 用, SDK 侧)
|
||||||
|
├── BoxServerHandler (Box 用, SDK 侧)
|
||||||
|
└── 匿名 Handler 实例 (Box 用, LangBot 侧)
|
||||||
|
|
||||||
|
Connection ← ABC
|
||||||
|
├── StdioConnection (stdio: 16KB chunks, 应用层分帧协议)
|
||||||
|
└── WebSocketConnection (WS: 64KB chunks, 原生 WS 分帧)
|
||||||
|
|
||||||
|
Controller ← ABC
|
||||||
|
├── StdioClientController (fork 子进程, pipe stdin/stdout)
|
||||||
|
├── StdioServerController (接管当前进程 stdin/stdout)
|
||||||
|
├── WebSocketClientController (连接 WS 服务端)
|
||||||
|
└── WebSocketServerController (监听 WS 端口)
|
||||||
|
```
|
||||||
|
|
||||||
|
共享的核心机制:
|
||||||
|
- `call_action()` / `call_action_generator()` — RPC 调用/流式调用
|
||||||
|
- `ActionRequest` / `ActionResponse` — 请求/响应协议
|
||||||
|
- `seq_id` 关联 — 并发请求复用单连接
|
||||||
|
- `CommonAction.PING` — 两者都用于初始握手
|
||||||
|
- 文件传输 (`send_file`) — Plugin 用,Box 不用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 端口方案
|
||||||
|
|
||||||
|
| 服务 | Plugin | Box |
|
||||||
|
|------|--------|-----|
|
||||||
|
| Action RPC (stdio) | stdin/stdout | stdin/stdout |
|
||||||
|
| Action RPC (WS) | `:5400` | `:5410/rpc/ws` |
|
||||||
|
| 辅助服务 | debug WS `:5401` | managed process WS relay `:5410/v1/sessions/{id}/managed-process/ws` |
|
||||||
|
|
||||||
|
**Box 特点**: 单端口 aiohttp 服务(默认 5410),通过路径区分 Action RPC 和 managed process relay。即使在 stdio 模式,也在 `:5410` 启动 aiohttp 用于 managed process attach。Plugin 在 stdio 模式不开额外端口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 销毁对比
|
||||||
|
|
||||||
|
### Plugin
|
||||||
|
|
||||||
|
```python
|
||||||
|
dispose():
|
||||||
|
if stdio: ctrl.process.terminate()
|
||||||
|
_dispose_subprocess() # Windows 子进程
|
||||||
|
heartbeat_task.cancel()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Box
|
||||||
|
|
||||||
|
```python
|
||||||
|
connector.dispose():
|
||||||
|
_handler_task.cancel()
|
||||||
|
_ctrl_task.cancel()
|
||||||
|
_subprocess.terminate()
|
||||||
|
|
||||||
|
service.dispose():
|
||||||
|
connector.dispose()
|
||||||
|
loop.create_task(client.shutdown()) # RPC SHUTDOWN → 清理所有容器
|
||||||
|
```
|
||||||
|
|
||||||
|
Box 的 RPC SHUTDOWN 确保容器被正确停止,不会成为孤儿。Plugin 直接杀进程。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 改进建议
|
||||||
|
|
||||||
|
### P0
|
||||||
|
|
||||||
|
1. **两者都加 WS 认证**: 至少 token 认证(INIT 时下发,连接时校验)
|
||||||
|
|
||||||
|
### P1
|
||||||
|
|
||||||
|
2. **考虑 Box 继承 ManagedRuntimeConnector**: 复用 `_start_runtime_subprocess` / `_wait_until_ready` / `_dispose_subprocess`,减少重复代码
|
||||||
|
3. **Plugin 重连加退避**: 固定 3s 无 backoff 可能造成日志洪水,建议向 Box 的指数退避看齐
|
||||||
|
4. **统一连接管理模式**: Event-based (Box) vs direct-await (Plugin),考虑收敛为一种
|
||||||
|
|
||||||
|
### 已完成(自上一轮)
|
||||||
|
|
||||||
|
- ~~Box 加重连~~(commit `2dfd9d5d`)
|
||||||
|
- ~~Box 加心跳~~(20s loop 与 Plugin 一致)
|
||||||
|
- ~~Box 加 Windows 支持~~(commit `120817a` / `fafb7a4`)
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"url": "https://langbot.app"
|
"url": "https://langbot.app"
|
||||||
},
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"name": "AGPL-3.0",
|
"name": "Apache-2.0",
|
||||||
"url": "https://github.com/langbot-app/LangBot/blob/master/LICENSE"
|
"url": "https://github.com/langbot-app/LangBot/blob/master/LICENSE"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.6.0-beta.1"
|
version = "4.10.1"
|
||||||
description = "Easy-to-use global IM bot platform designed for LLM era"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
requires-python = ">=3.10.1,<4.0"
|
requires-python = ">=3.11,<4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiocqhttp>=1.4.4",
|
"aiocqhttp>=1.4.4",
|
||||||
"aiofiles>=24.1.0",
|
"aiofiles>=24.1.0",
|
||||||
"aiohttp>=3.11.18",
|
"aiohttp>=3.14.0",
|
||||||
"aioshutil>=1.5",
|
"aioshutil>=1.5",
|
||||||
"aiosqlite>=0.21.0",
|
"aiosqlite>=0.21.0",
|
||||||
"anthropic>=0.51.0",
|
"anthropic>=0.51.0",
|
||||||
@@ -16,41 +16,42 @@ dependencies = [
|
|||||||
"async-lru>=2.0.5",
|
"async-lru>=2.0.5",
|
||||||
"certifi>=2025.4.26",
|
"certifi>=2025.4.26",
|
||||||
"colorlog~=6.6.0",
|
"colorlog~=6.6.0",
|
||||||
"cryptography>=44.0.3",
|
"cryptography>=46.0.7",
|
||||||
"dashscope>=1.23.2",
|
"dashscope>=1.25.10",
|
||||||
"dingtalk-stream>=0.24.0",
|
"dingtalk-stream>=0.24.0",
|
||||||
"discord-py>=2.5.2",
|
"discord-py>=2.5.2",
|
||||||
"pynacl>=1.5.0", # Required for Discord voice support
|
"pynacl>=1.5.0", # Required for Discord voice support
|
||||||
"gewechat-client>=0.1.5",
|
"gewechat-client>=0.1.5",
|
||||||
"lark-oapi>=1.4.15",
|
"lark-oapi>=1.5.5",
|
||||||
"mcp>=1.8.1",
|
"mcp>=1.25.0",
|
||||||
"nakuru-project-idk>=0.0.2.1",
|
"nakuru-project-idk>=0.0.2.1",
|
||||||
"ollama>=0.4.8",
|
"ollama>=0.4.8",
|
||||||
"openai>1.0.0",
|
"openai>1.0.0",
|
||||||
"pillow>=11.2.1",
|
"pillow>=12.2.0",
|
||||||
"psutil>=7.0.0",
|
"psutil>=7.0.0",
|
||||||
"pycryptodome>=3.22.0",
|
"pycryptodome>=3.22.0",
|
||||||
"pydantic>2.0",
|
"pydantic>2.0",
|
||||||
"pyjwt>=2.10.1",
|
"pyjwt>=2.12.0",
|
||||||
"python-telegram-bot>=22.0",
|
"python-telegram-bot>=22.0",
|
||||||
"pyyaml>=6.0.2",
|
"pyyaml>=6.0.2",
|
||||||
"qq-botpy-rc>=1.2.1.6",
|
"qq-botpy-rc>=1.2.1.6",
|
||||||
|
"qrcode>=7.4",
|
||||||
"quart>=0.20.0",
|
"quart>=0.20.0",
|
||||||
"quart-cors>=0.8.0",
|
"quart-cors>=0.8.0",
|
||||||
"requests>=2.32.3",
|
"requests>=2.33.0",
|
||||||
"slack-sdk>=3.35.0",
|
"slack-sdk>=3.35.0",
|
||||||
|
"alembic>=1.15.0",
|
||||||
"sqlalchemy[asyncio]>=2.0.40",
|
"sqlalchemy[asyncio]>=2.0.40",
|
||||||
"sqlmodel>=0.0.24",
|
"sqlmodel>=0.0.24",
|
||||||
"telegramify-markdown>=0.5.1",
|
"telegramify-markdown>=0.5.1",
|
||||||
"tiktoken>=0.9.0",
|
"tiktoken>=0.9.0",
|
||||||
"urllib3>=2.4.0",
|
"urllib3>=2.7.0",
|
||||||
"websockets>=15.0.1",
|
"websockets>=15.0.1",
|
||||||
"python-socks>=2.7.1", # dingtalk missing dependency
|
"python-socks>=2.7.1", # dingtalk missing dependency
|
||||||
"taskgroup==0.0.0a4", # graingert/taskgroup#20
|
"pip>=26.1",
|
||||||
"pip>=25.1.1",
|
|
||||||
"ruff>=0.11.9",
|
"ruff>=0.11.9",
|
||||||
"pre-commit>=4.2.0",
|
"pre-commit>=4.2.0",
|
||||||
"uv>=0.7.11",
|
"uv>=0.11.15",
|
||||||
"mypy>=1.16.0",
|
"mypy>=1.16.0",
|
||||||
"PyPDF2>=3.0.1",
|
"PyPDF2>=3.0.1",
|
||||||
"python-docx>=1.1.0",
|
"python-docx>=1.1.0",
|
||||||
@@ -61,14 +62,23 @@ dependencies = [
|
|||||||
"ebooklib>=0.18",
|
"ebooklib>=0.18",
|
||||||
"html2text>=2024.2.26",
|
"html2text>=2024.2.26",
|
||||||
"langchain>=0.2.0",
|
"langchain>=0.2.0",
|
||||||
"langchain-text-splitters>=0.0.1",
|
"langchain-core>=1.3.3",
|
||||||
"chromadb>=0.4.24",
|
"langsmith>=0.8.0",
|
||||||
|
"python-multipart>=0.0.27",
|
||||||
|
"Mako>=1.3.12",
|
||||||
|
"langchain-text-splitters>=1.1.2",
|
||||||
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"langbot-plugin==0.1.11b1",
|
"pyseekdb==1.1.0.post3",
|
||||||
|
"langbot-plugin==0.5.0a2",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
|
"matrix-nio>=0.25.2",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
"boto3>=1.35.0",
|
"boto3>=1.35.0",
|
||||||
|
"pymilvus>=2.6.4",
|
||||||
|
"pgvector>=0.4.1",
|
||||||
|
"botocore>=1.42.39",
|
||||||
]
|
]
|
||||||
keywords = [
|
keywords = [
|
||||||
"bot",
|
"bot",
|
||||||
@@ -108,12 +118,13 @@ requires = ["setuptools>=61.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-data = { "langbot" = ["templates/*", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
|
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "pkg/platform/adapters/**", "web/dist/**", "pkg/persistence/alembic/**"] }
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
"moto>=5.2.1",
|
||||||
"pre-commit>=4.2.0",
|
"pre-commit>=4.2.0",
|
||||||
"pytest>=8.4.1",
|
"pytest>=9.0.3",
|
||||||
"pytest-asyncio>=1.0.0",
|
"pytest-asyncio>=1.0.0",
|
||||||
"pytest-cov>=7.0.0",
|
"pytest-cov>=7.0.0",
|
||||||
"ruff>=0.11.9",
|
"ruff>=0.11.9",
|
||||||
@@ -212,4 +223,3 @@ skip-magic-trailing-comma = false
|
|||||||
|
|
||||||
# Like Black, automatically detect the appropriate line ending.
|
# Like Black, automatically detect the appropriate line ending.
|
||||||
line-ending = "auto"
|
line-ending = "auto"
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ python_files = test_*.py
|
|||||||
python_classes = Test*
|
python_classes = Test*
|
||||||
python_functions = test_*
|
python_functions = test_*
|
||||||
|
|
||||||
|
# Python path for imports
|
||||||
|
pythonpath = . tests
|
||||||
|
|
||||||
# Test paths
|
# Test paths
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
|
|
||||||
@@ -22,11 +25,13 @@ markers =
|
|||||||
asyncio: mark test as async
|
asyncio: mark test as async
|
||||||
unit: mark test as unit test
|
unit: mark test as unit test
|
||||||
integration: mark test as integration test
|
integration: mark test as integration test
|
||||||
|
smoke: mark test as smoke test
|
||||||
slow: mark test as slow running
|
slow: mark test as slow running
|
||||||
|
e2e: mark test as end-to-end test (requires real LangBot process)
|
||||||
|
|
||||||
# Coverage options (when using pytest-cov)
|
# Coverage options (when using pytest-cov)
|
||||||
[coverage:run]
|
[coverage:run]
|
||||||
source = langbot.pkg
|
source = langbot
|
||||||
omit =
|
omit =
|
||||||
*/tests/*
|
*/tests/*
|
||||||
*/test_*.py
|
*/test_*.py
|
||||||
|
|||||||
BIN
res/logo-blue.png
Normal file
BIN
res/logo-blue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -22,7 +22,7 @@ echo "Running all unit tests..."
|
|||||||
|
|
||||||
# Run tests with coverage
|
# Run tests with coverage
|
||||||
pytest tests/unit_tests/ -v --tb=short \
|
pytest tests/unit_tests/ -v --tb=short \
|
||||||
--cov=pkg \
|
--cov=langbot \
|
||||||
--cov-report=xml \
|
--cov-report=xml \
|
||||||
"$@"
|
"$@"
|
||||||
|
|
||||||
|
|||||||
65
scripts/test-coverage.sh
Executable file
65
scripts/test-coverage.sh
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Coverage gate script
|
||||||
|
# Runs all tests with coverage, enforcing minimum coverage threshold
|
||||||
|
# Uses separate pytest invocations to avoid sys.modules pollution between test types
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== LangBot Coverage Gate ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Coverage threshold (baseline from current coverage, conservative buffer)
|
||||||
|
# Current: ~22.14%, threshold: 18%
|
||||||
|
COVERAGE_THRESHOLD=18
|
||||||
|
|
||||||
|
# Create temporary directory for coverage files
|
||||||
|
COV_DIR=$(mktemp -d)
|
||||||
|
trap "rm -rf $COV_DIR" EXIT
|
||||||
|
|
||||||
|
echo "[1/3] Running unit + smoke tests with coverage..."
|
||||||
|
uv run pytest tests/unit_tests/ tests/smoke/ \
|
||||||
|
--cov=langbot \
|
||||||
|
--cov-report=json:$COV_DIR/unit.json \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
-q --tb=short
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "[2/3] Running fast integration tests with coverage..."
|
||||||
|
uv run pytest tests/integration/ -m "not slow" \
|
||||||
|
--cov=langbot \
|
||||||
|
--cov-report=json:$COV_DIR/integration.json \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
-q --tb=short
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "[3/3] Combining coverage reports..."
|
||||||
|
# Use coverage combine if available, otherwise just report total
|
||||||
|
if command -v coverage &> /dev/null; then
|
||||||
|
# Combine JSON reports
|
||||||
|
coverage combine --keep $COV_DIR/unit.json $COV_DIR/integration.json \
|
||||||
|
--data-file=$COV_DIR/combined.data 2>/dev/null || true
|
||||||
|
|
||||||
|
coverage report --data-file=$COV_DIR/combined.data || true
|
||||||
|
else
|
||||||
|
echo "Note: coverage combine not available, showing individual reports above"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate final XML report for CI (from last run)
|
||||||
|
uv run pytest tests/unit_tests/ tests/smoke/ \
|
||||||
|
--cov=langbot \
|
||||||
|
--cov-report=xml:coverage.xml \
|
||||||
|
--cov-report=term \
|
||||||
|
--cov-fail-under=$COVERAGE_THRESHOLD \
|
||||||
|
-q 2>/dev/null || {
|
||||||
|
# If threshold check fails on combined, check unit+smoke baseline
|
||||||
|
echo ""
|
||||||
|
echo "Coverage threshold: $COVERAGE_THRESHOLD%"
|
||||||
|
echo "Note: Full coverage requires running all test types separately"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Coverage Gate Complete ==="
|
||||||
|
echo ""
|
||||||
|
echo "Coverage baseline: $COVERAGE_THRESHOLD%"
|
||||||
|
echo "Coverage report saved to coverage.xml"
|
||||||
16
scripts/test-integration-fast.sh
Executable file
16
scripts/test-integration-fast.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Fast integration tests
|
||||||
|
# Runs integration tests excluding slow ones (PostgreSQL, external services)
|
||||||
|
# Uses fake runner/provider, no real credentials needed
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== LangBot Fast Integration Tests ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Running integration tests (excluding slow)..."
|
||||||
|
uv run pytest tests/integration/ -m "not slow" -q --tb=short
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Fast Integration Tests Complete ==="
|
||||||
36
scripts/test-quick.sh
Executable file
36
scripts/test-quick.sh
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Quick developer self-test command
|
||||||
|
# Runs linting, unit tests, and smoke tests without requiring real provider keys
|
||||||
|
# Suitable for local branch validation
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== LangBot Quick Self-Test ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Ruff check
|
||||||
|
echo "[1/3] Running ruff check..."
|
||||||
|
uv run ruff check src/langbot/ tests/ --output-format=concise || {
|
||||||
|
echo ""
|
||||||
|
echo "⚠ Ruff check found issues. Run 'uv run ruff check --fix' to auto-fix."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "✓ Ruff check passed"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 2. Unit tests
|
||||||
|
echo "[2/3] Running unit tests..."
|
||||||
|
uv run pytest tests/unit_tests/ -q --tb=short
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 3. Smoke tests (if exists)
|
||||||
|
echo "[3/3] Running smoke tests..."
|
||||||
|
if [ -d "tests/smoke" ]; then
|
||||||
|
uv run pytest tests/smoke/ -q --tb=short
|
||||||
|
else
|
||||||
|
echo "No smoke tests found, skipping"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Quick Self-Test Complete ==="
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Easy-to-use global IM bot platform designed for LLM era"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.6.0-beta.1'
|
__version__ = '4.10.1'
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import argparse
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from langbot.pkg.utils import paths
|
||||||
|
|
||||||
# ASCII art banner
|
# ASCII art banner
|
||||||
asciiart = r"""
|
asciiart = r"""
|
||||||
_ ___ _
|
_ ___ _
|
||||||
@@ -27,6 +29,12 @@ async def main_entry(loop: asyncio.AbstractEventLoop):
|
|||||||
help='Use standalone plugin runtime / 使用独立插件运行时',
|
help='Use standalone plugin runtime / 使用独立插件运行时',
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--standalone-box',
|
||||||
|
action='store_true',
|
||||||
|
help='Use standalone box runtime / 使用独立 Box 运行时',
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False)
|
parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -35,6 +43,11 @@ async def main_entry(loop: asyncio.AbstractEventLoop):
|
|||||||
|
|
||||||
platform.standalone_runtime = True
|
platform.standalone_runtime = True
|
||||||
|
|
||||||
|
if args.standalone_box:
|
||||||
|
from langbot.pkg.utils import platform
|
||||||
|
|
||||||
|
platform.standalone_box = True
|
||||||
|
|
||||||
if args.debug:
|
if args.debug:
|
||||||
from langbot.pkg.utils import constants
|
from langbot.pkg.utils import constants
|
||||||
|
|
||||||
@@ -87,7 +100,7 @@ def main():
|
|||||||
# Set up the working directory
|
# Set up the working directory
|
||||||
# When installed as a package, we need to handle the working directory differently
|
# When installed as a package, we need to handle the working directory differently
|
||||||
# We'll create data directory in current working directory if not exists
|
# We'll create data directory in current working directory if not exists
|
||||||
os.makedirs('data', exist_ok=True)
|
os.makedirs(paths.get_data_root(), exist_ok=True)
|
||||||
|
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
|
|
||||||
|
|||||||
5
src/langbot/libs/deerflow_api/__init__.py
Normal file
5
src/langbot/libs/deerflow_api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from .client import AsyncDeerFlowClient
|
||||||
|
from .errors import DeerFlowAPIError
|
||||||
|
from . import stream_utils
|
||||||
|
|
||||||
|
__all__ = ['AsyncDeerFlowClient', 'DeerFlowAPIError', 'stream_utils']
|
||||||
204
src/langbot/libs/deerflow_api/client.py
Normal file
204
src/langbot/libs/deerflow_api/client.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""DeerFlow LangGraph HTTP API 客户端
|
||||||
|
|
||||||
|
参考 astrbot 的 deerflow_api_client 实现,使用 httpx 适配 LangBot 风格。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import codecs
|
||||||
|
import json
|
||||||
|
import typing
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .errors import DeerFlowAPIError
|
||||||
|
|
||||||
|
|
||||||
|
SSE_MAX_BUFFER_CHARS = 1_048_576
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sse_newlines(text: str) -> str:
|
||||||
|
"""规范化 CRLF/CR 为 LF,确保 SSE 块分割稳定"""
|
||||||
|
return text.replace('\r\n', '\n').replace('\r', '\n')
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sse_data_lines(data_lines: list[str]) -> typing.Any:
|
||||||
|
raw_data = '\n'.join(data_lines)
|
||||||
|
try:
|
||||||
|
return json.loads(raw_data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# 某些 LangGraph 兼容服务端会在单个 SSE 事件中用多个 data 行
|
||||||
|
# 发送多段 JSON 片段(例如 tuple payload)
|
||||||
|
parsed_lines: list[typing.Any] = []
|
||||||
|
can_parse_all = True
|
||||||
|
for line in data_lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed_lines.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
can_parse_all = False
|
||||||
|
break
|
||||||
|
if can_parse_all and parsed_lines:
|
||||||
|
return parsed_lines[0] if len(parsed_lines) == 1 else parsed_lines
|
||||||
|
return raw_data
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sse_block(block: str) -> dict[str, typing.Any] | None:
|
||||||
|
if not block.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
event_name = 'message'
|
||||||
|
data_lines: list[str] = []
|
||||||
|
for line in block.splitlines():
|
||||||
|
if line.startswith('event:'):
|
||||||
|
event_name = line[6:].strip()
|
||||||
|
elif line.startswith('data:'):
|
||||||
|
data_lines.append(line[5:].lstrip())
|
||||||
|
|
||||||
|
if not data_lines:
|
||||||
|
return None
|
||||||
|
return {'event': event_name, 'data': _parse_sse_data_lines(data_lines)}
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncDeerFlowClient:
|
||||||
|
"""DeerFlow LangGraph HTTP API 客户端"""
|
||||||
|
|
||||||
|
api_base: str
|
||||||
|
headers: dict[str, str]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_base: str = 'http://127.0.0.1:2026',
|
||||||
|
api_key: str = '',
|
||||||
|
auth_header: str = '',
|
||||||
|
) -> None:
|
||||||
|
self.api_base = api_base.rstrip('/')
|
||||||
|
self.headers: dict[str, str] = {}
|
||||||
|
if auth_header:
|
||||||
|
self.headers['Authorization'] = auth_header
|
||||||
|
elif api_key:
|
||||||
|
self.headers['Authorization'] = f'Bearer {api_key}'
|
||||||
|
|
||||||
|
async def create_thread(self, timeout: float = 20) -> dict[str, typing.Any]:
|
||||||
|
"""创建一个新的 LangGraph thread
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含 thread_id 等信息的字典
|
||||||
|
"""
|
||||||
|
url = f'{self.api_base}/api/langgraph/threads'
|
||||||
|
payload = {'metadata': {}}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
trust_env=True,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
headers=self.headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
raise DeerFlowAPIError(
|
||||||
|
operation='create thread',
|
||||||
|
status=response.status_code,
|
||||||
|
body=response.text,
|
||||||
|
url=url,
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def delete_thread(self, thread_id: str, timeout: float = 20) -> None:
|
||||||
|
"""删除指定 thread"""
|
||||||
|
url = f'{self.api_base}/api/threads/{thread_id}'
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
trust_env=True,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
|
response = await client.delete(url, headers=self.headers)
|
||||||
|
if response.status_code not in (200, 202, 204, 404):
|
||||||
|
raise DeerFlowAPIError(
|
||||||
|
operation='delete thread',
|
||||||
|
status=response.status_code,
|
||||||
|
body=response.text,
|
||||||
|
url=url,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stream_run(
|
||||||
|
self,
|
||||||
|
thread_id: str,
|
||||||
|
payload: dict[str, typing.Any],
|
||||||
|
timeout: float = 120,
|
||||||
|
) -> AsyncGenerator[dict[str, typing.Any], None]:
|
||||||
|
"""运行一次 LangGraph stream 请求,逐事件 yield
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
事件字典 {'event': event_name, 'data': parsed_data}
|
||||||
|
"""
|
||||||
|
url = f'{self.api_base}/api/langgraph/threads/{thread_id}/runs/stream'
|
||||||
|
|
||||||
|
# 流式请求使用单独的 read timeout 控制
|
||||||
|
stream_timeout = httpx.Timeout(
|
||||||
|
connect=min(timeout, 30),
|
||||||
|
read=timeout,
|
||||||
|
write=timeout,
|
||||||
|
pool=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
trust_env=True,
|
||||||
|
timeout=stream_timeout,
|
||||||
|
) as client:
|
||||||
|
async with client.stream(
|
||||||
|
'POST',
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
**self.headers,
|
||||||
|
'Accept': 'text/event-stream',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
) as resp:
|
||||||
|
if resp.status_code != 200:
|
||||||
|
body = await resp.aread()
|
||||||
|
raise DeerFlowAPIError(
|
||||||
|
operation='runs/stream request',
|
||||||
|
status=resp.status_code,
|
||||||
|
body=body.decode('utf-8', errors='replace'),
|
||||||
|
url=url,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
decoder = codecs.getincrementaldecoder('utf-8')('replace')
|
||||||
|
buffer = ''
|
||||||
|
|
||||||
|
async for chunk in resp.aiter_bytes(8192):
|
||||||
|
buffer += _normalize_sse_newlines(decoder.decode(chunk))
|
||||||
|
|
||||||
|
while '\n\n' in buffer:
|
||||||
|
block, buffer = buffer.split('\n\n', 1)
|
||||||
|
parsed = _parse_sse_block(block)
|
||||||
|
if parsed is not None:
|
||||||
|
yield parsed
|
||||||
|
|
||||||
|
if len(buffer) > SSE_MAX_BUFFER_CHARS:
|
||||||
|
# 缓冲区过大,强制 flush
|
||||||
|
parsed = _parse_sse_block(buffer)
|
||||||
|
if parsed is not None:
|
||||||
|
yield parsed
|
||||||
|
buffer = ''
|
||||||
|
|
||||||
|
# flush 剩余内容
|
||||||
|
buffer += _normalize_sse_newlines(decoder.decode(b'', final=True))
|
||||||
|
while '\n\n' in buffer:
|
||||||
|
block, buffer = buffer.split('\n\n', 1)
|
||||||
|
parsed = _parse_sse_block(block)
|
||||||
|
if parsed is not None:
|
||||||
|
yield parsed
|
||||||
|
if buffer.strip():
|
||||||
|
parsed = _parse_sse_block(buffer)
|
||||||
|
if parsed is not None:
|
||||||
|
yield parsed
|
||||||
30
src/langbot/libs/deerflow_api/errors.py
Normal file
30
src/langbot/libs/deerflow_api/errors.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class DeerFlowAPIError(Exception):
|
||||||
|
"""DeerFlow API 请求失败"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
operation: str = '',
|
||||||
|
status: int = 0,
|
||||||
|
body: str = '',
|
||||||
|
url: str = '',
|
||||||
|
thread_id: str | None = None,
|
||||||
|
message: str = '',
|
||||||
|
) -> None:
|
||||||
|
self.operation = operation
|
||||||
|
self.status = status
|
||||||
|
self.body = body
|
||||||
|
self.url = url
|
||||||
|
self.thread_id = thread_id
|
||||||
|
|
||||||
|
if message:
|
||||||
|
super().__init__(message)
|
||||||
|
return
|
||||||
|
|
||||||
|
msg = f'DeerFlow {operation} failed: status={status}, url={url}, body={body}'
|
||||||
|
if thread_id is not None:
|
||||||
|
msg = f'DeerFlow {operation} failed: thread_id={thread_id}, status={status}, url={url}, body={body}'
|
||||||
|
super().__init__(msg)
|
||||||
212
src/langbot/libs/deerflow_api/stream_utils.py
Normal file
212
src/langbot/libs/deerflow_api/stream_utils.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""DeerFlow LangGraph 流式响应解析工具
|
||||||
|
|
||||||
|
参考 astrbot 实现的 deerflow_stream_utils。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typing
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text(content: typing.Any) -> str:
|
||||||
|
"""从消息 content 中提取纯文本"""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, dict):
|
||||||
|
if isinstance(content.get('text'), str):
|
||||||
|
return content['text']
|
||||||
|
if 'content' in content:
|
||||||
|
return extract_text(content.get('content'))
|
||||||
|
if 'kwargs' in content and isinstance(content['kwargs'], dict):
|
||||||
|
return extract_text(content['kwargs'].get('content'))
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts: list[str] = []
|
||||||
|
for item in content:
|
||||||
|
if isinstance(item, str):
|
||||||
|
parts.append(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
item_type = item.get('type')
|
||||||
|
if item_type == 'text' and isinstance(item.get('text'), str):
|
||||||
|
parts.append(item['text'])
|
||||||
|
elif 'content' in item:
|
||||||
|
parts.append(extract_text(item['content']))
|
||||||
|
return '\n'.join([p for p in parts if p]).strip()
|
||||||
|
return str(content) if content is not None else ''
|
||||||
|
|
||||||
|
|
||||||
|
def extract_messages_from_values_data(data: typing.Any) -> list[typing.Any]:
|
||||||
|
"""从 values 事件中提取 messages 列表"""
|
||||||
|
candidates: list[typing.Any] = []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
candidates.append(data)
|
||||||
|
if isinstance(data.get('values'), dict):
|
||||||
|
candidates.append(data['values'])
|
||||||
|
elif isinstance(data, list):
|
||||||
|
candidates.extend([x for x in data if isinstance(x, dict)])
|
||||||
|
|
||||||
|
for item in candidates:
|
||||||
|
messages = item.get('messages')
|
||||||
|
if isinstance(messages, list):
|
||||||
|
return messages
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def is_ai_message(message: dict[str, typing.Any]) -> bool:
|
||||||
|
"""判断是否为 AI/assistant 消息"""
|
||||||
|
role = str(message.get('role', '')).lower()
|
||||||
|
if role in {'assistant', 'ai'}:
|
||||||
|
return True
|
||||||
|
|
||||||
|
msg_type = str(message.get('type', '')).lower()
|
||||||
|
if msg_type in {'ai', 'assistant', 'aimessage', 'aimessagechunk'}:
|
||||||
|
return True
|
||||||
|
if 'ai' in msg_type and all(token not in msg_type for token in ('human', 'tool', 'system')):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_latest_ai_text(messages: Iterable[typing.Any]) -> str:
|
||||||
|
"""获取最近一条 AI 消息的文本内容"""
|
||||||
|
if isinstance(messages, (list, tuple)):
|
||||||
|
iterable = reversed(messages)
|
||||||
|
else:
|
||||||
|
iterable = reversed(list(messages))
|
||||||
|
|
||||||
|
for msg in iterable:
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
if is_ai_message(msg):
|
||||||
|
text = extract_text(msg.get('content'))
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def extract_latest_ai_message(messages: Iterable[typing.Any]) -> dict[str, typing.Any] | None:
|
||||||
|
"""获取最近一条 AI 消息对象"""
|
||||||
|
if isinstance(messages, (list, tuple)):
|
||||||
|
iterable = reversed(messages)
|
||||||
|
else:
|
||||||
|
iterable = reversed(list(messages))
|
||||||
|
|
||||||
|
for msg in iterable:
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
if is_ai_message(msg):
|
||||||
|
return msg
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_clarification_tool_message(message: dict[str, typing.Any]) -> bool:
|
||||||
|
"""判断是否为澄清问题工具消息"""
|
||||||
|
msg_type = str(message.get('type', '')).lower()
|
||||||
|
tool_name = str(message.get('name', '')).lower()
|
||||||
|
return msg_type == 'tool' and tool_name == 'ask_clarification'
|
||||||
|
|
||||||
|
|
||||||
|
def extract_latest_clarification_text(messages: Iterable[typing.Any]) -> str:
|
||||||
|
"""提取最近的澄清问题文本"""
|
||||||
|
if isinstance(messages, (list, tuple)):
|
||||||
|
iterable = reversed(messages)
|
||||||
|
else:
|
||||||
|
iterable = reversed(list(messages))
|
||||||
|
|
||||||
|
for msg in iterable:
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
if is_clarification_tool_message(msg):
|
||||||
|
text = extract_text(msg.get('content'))
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def get_message_id(message: typing.Any) -> str:
|
||||||
|
"""提取消息 ID"""
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
return ''
|
||||||
|
msg_id = message.get('id')
|
||||||
|
return msg_id if isinstance(msg_id, str) else ''
|
||||||
|
|
||||||
|
|
||||||
|
def extract_event_message_obj(data: typing.Any) -> dict[str, typing.Any] | None:
|
||||||
|
"""从事件 data 中提取消息对象"""
|
||||||
|
msg_obj = data
|
||||||
|
if isinstance(data, (list, tuple)) and data:
|
||||||
|
msg_obj = data[0]
|
||||||
|
if isinstance(msg_obj, dict) and isinstance(msg_obj.get('data'), dict):
|
||||||
|
msg_obj = msg_obj['data']
|
||||||
|
return msg_obj if isinstance(msg_obj, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_ai_delta_from_event_data(data: typing.Any) -> str:
|
||||||
|
"""从 messages-tuple 事件中提取 AI delta 文本"""
|
||||||
|
msg_obj = extract_event_message_obj(data)
|
||||||
|
if not msg_obj:
|
||||||
|
return ''
|
||||||
|
if is_ai_message(msg_obj):
|
||||||
|
return extract_text(msg_obj.get('content'))
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def extract_clarification_from_event_data(data: typing.Any) -> str:
|
||||||
|
"""从事件中提取澄清问题"""
|
||||||
|
msg_obj = extract_event_message_obj(data)
|
||||||
|
if not msg_obj:
|
||||||
|
return ''
|
||||||
|
if is_clarification_tool_message(msg_obj):
|
||||||
|
return extract_text(msg_obj.get('content'))
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_custom_event_items(data: typing.Any) -> list[dict[str, typing.Any]]:
|
||||||
|
items: list[dict[str, typing.Any]] = []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return [data]
|
||||||
|
if isinstance(data, list):
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
items.append(item)
|
||||||
|
elif isinstance(item, (list, tuple)):
|
||||||
|
for nested in item:
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
items.append(nested)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def extract_task_failures_from_custom_event(data: typing.Any) -> list[str]:
|
||||||
|
"""从 custom 事件中提取子任务失败信息"""
|
||||||
|
failures: list[str] = []
|
||||||
|
for item in _iter_custom_event_items(data):
|
||||||
|
event_type = str(item.get('type', '')).lower()
|
||||||
|
if event_type not in {'task_failed', 'task_timed_out'}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
task_id = str(item.get('task_id', '')).strip()
|
||||||
|
error_text = extract_text(item.get('error')).strip()
|
||||||
|
if task_id and error_text:
|
||||||
|
failures.append(f'{task_id}: {error_text}')
|
||||||
|
elif error_text:
|
||||||
|
failures.append(error_text)
|
||||||
|
elif task_id:
|
||||||
|
failures.append(f'{task_id}: unknown error')
|
||||||
|
else:
|
||||||
|
failures.append('unknown task failure')
|
||||||
|
return failures
|
||||||
|
|
||||||
|
|
||||||
|
def build_task_failure_summary(failures: list[str]) -> str:
|
||||||
|
"""构建任务失败摘要"""
|
||||||
|
if not failures:
|
||||||
|
return ''
|
||||||
|
deduped: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for failure in failures:
|
||||||
|
if failure not in seen:
|
||||||
|
seen.add(failure)
|
||||||
|
deduped.append(failure)
|
||||||
|
if len(deduped) == 1:
|
||||||
|
return f'DeerFlow subtask failed: {deduped[0]}'
|
||||||
|
joined = '\n'.join([f'- {item}' for item in deduped[:5]])
|
||||||
|
return f'DeerFlow subtasks failed:\n{joined}'
|
||||||
@@ -32,6 +32,7 @@ class AsyncDifyServiceClient:
|
|||||||
conversation_id: str = '',
|
conversation_id: str = '',
|
||||||
files: list[dict[str, typing.Any]] = [],
|
files: list[dict[str, typing.Any]] = [],
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
|
model_config: dict[str, typing.Any] | None = None,
|
||||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||||
"""发送消息"""
|
"""发送消息"""
|
||||||
if response_mode != 'streaming':
|
if response_mode != 'streaming':
|
||||||
@@ -42,6 +43,16 @@ class AsyncDifyServiceClient:
|
|||||||
trust_env=True,
|
trust_env=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
) as client:
|
) as client:
|
||||||
|
payload = {
|
||||||
|
'inputs': inputs,
|
||||||
|
'query': query,
|
||||||
|
'user': user,
|
||||||
|
'response_mode': response_mode,
|
||||||
|
'conversation_id': conversation_id,
|
||||||
|
'files': files,
|
||||||
|
'model_config': model_config or {},
|
||||||
|
}
|
||||||
|
|
||||||
async with client.stream(
|
async with client.stream(
|
||||||
'POST',
|
'POST',
|
||||||
'/chat-messages',
|
'/chat-messages',
|
||||||
@@ -49,14 +60,7 @@ class AsyncDifyServiceClient:
|
|||||||
'Authorization': f'Bearer {self.api_key}',
|
'Authorization': f'Bearer {self.api_key}',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
json={
|
json=payload,
|
||||||
'inputs': inputs,
|
|
||||||
'query': query,
|
|
||||||
'user': user,
|
|
||||||
'response_mode': response_mode,
|
|
||||||
'conversation_id': conversation_id,
|
|
||||||
'files': files,
|
|
||||||
},
|
|
||||||
) as r:
|
) as r:
|
||||||
async for chunk in r.aiter_lines():
|
async for chunk in r.aiter_lines():
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
@@ -141,7 +145,7 @@ class AsyncDifyServiceClient:
|
|||||||
'file': file,
|
'file': file,
|
||||||
},
|
},
|
||||||
data={
|
data={
|
||||||
'user': (None, user),
|
'user': user,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
import urllib.parse
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
import dingtalk_stream # type: ignore
|
import dingtalk_stream # type: ignore
|
||||||
|
import websockets
|
||||||
from .EchoHandler import EchoTextHandler
|
from .EchoHandler import EchoTextHandler
|
||||||
from .dingtalkevent import DingTalkEvent
|
from .dingtalkevent import DingTalkEvent
|
||||||
import httpx
|
import httpx
|
||||||
@@ -36,6 +39,7 @@ class DingTalkClient:
|
|||||||
self.access_token_expiry_time = ''
|
self.access_token_expiry_time = ''
|
||||||
self.markdown_card = markdown_card
|
self.markdown_card = markdown_card
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
self._stopped = False # Flag to control the event loop
|
||||||
|
|
||||||
async def get_access_token(self):
|
async def get_access_token(self):
|
||||||
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
|
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
|
||||||
@@ -170,11 +174,96 @@ class DingTalkClient:
|
|||||||
"""
|
"""
|
||||||
处理消息事件。
|
处理消息事件。
|
||||||
"""
|
"""
|
||||||
|
# Skip message handling if stopped
|
||||||
|
if self._stopped:
|
||||||
|
return
|
||||||
msg_type = event.conversation
|
msg_type = event.conversation
|
||||||
if msg_type in self._message_handlers:
|
if msg_type in self._message_handlers:
|
||||||
for handler in self._message_handlers[msg_type]:
|
for handler in self._message_handlers[msg_type]:
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|
||||||
|
async def _parse_quoted_message(self, replied_msg: dict) -> dict:
|
||||||
|
"""Parse the quoted/replied message and extract its content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
replied_msg: The repliedMsg object from DingTalk message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict containing the quoted message info with keys:
|
||||||
|
- message_id: The original message ID
|
||||||
|
- msg_type: The message type (text, file, picture, audio, etc.)
|
||||||
|
- content: The text content (if any)
|
||||||
|
- file_url: The file download URL (if file type)
|
||||||
|
- file_name: The file name (if file type)
|
||||||
|
- picture: The picture base64 (if picture type)
|
||||||
|
- audio: The audio base64 (if audio type)
|
||||||
|
"""
|
||||||
|
quote_info = {
|
||||||
|
'message_id': replied_msg.get('msgId', ''),
|
||||||
|
'msg_type': replied_msg.get('msgType', ''),
|
||||||
|
'sender_id': replied_msg.get('senderId', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg_type = replied_msg.get('msgType', '')
|
||||||
|
content = replied_msg.get('content', {})
|
||||||
|
|
||||||
|
# Handle content as string (JSON) or dict
|
||||||
|
if isinstance(content, str):
|
||||||
|
try:
|
||||||
|
content = json.loads(content)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
content = {}
|
||||||
|
|
||||||
|
if msg_type == 'text':
|
||||||
|
# Text message
|
||||||
|
if isinstance(content, dict):
|
||||||
|
quote_info['content'] = content.get('content', '')
|
||||||
|
else:
|
||||||
|
quote_info['content'] = str(content)
|
||||||
|
|
||||||
|
elif msg_type == 'file':
|
||||||
|
# File message
|
||||||
|
download_code = content.get('downloadCode')
|
||||||
|
file_name = content.get('fileName')
|
||||||
|
if download_code and file_name:
|
||||||
|
try:
|
||||||
|
quote_info['file_url'] = await self.get_file_url(download_code)
|
||||||
|
quote_info['file_name'] = file_name
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'Failed to get quoted file URL: {e}')
|
||||||
|
|
||||||
|
elif msg_type == 'picture':
|
||||||
|
# Picture message
|
||||||
|
download_code = content.get('downloadCode')
|
||||||
|
if download_code:
|
||||||
|
try:
|
||||||
|
quote_info['picture'] = await self.download_image(download_code)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'Failed to download quoted image: {e}')
|
||||||
|
|
||||||
|
elif msg_type == 'audio':
|
||||||
|
# Audio message
|
||||||
|
download_code = content.get('downloadCode')
|
||||||
|
if download_code:
|
||||||
|
try:
|
||||||
|
quote_info['audio'] = await self.get_audio_url(download_code)
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'Failed to get quoted audio: {e}')
|
||||||
|
|
||||||
|
elif msg_type == 'richText':
|
||||||
|
# Rich text message - extract text content
|
||||||
|
rich_text = content.get('richText', [])
|
||||||
|
texts = []
|
||||||
|
for item in rich_text:
|
||||||
|
if 'text' in item and item['text'] != '\n':
|
||||||
|
texts.append(item['text'])
|
||||||
|
quote_info['content'] = '\n'.join(texts)
|
||||||
|
|
||||||
|
return quote_info
|
||||||
|
|
||||||
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
|
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
|
||||||
try:
|
try:
|
||||||
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
||||||
@@ -186,6 +275,15 @@ class DingTalkClient:
|
|||||||
elif str(incoming_message.conversation_type) == '2':
|
elif str(incoming_message.conversation_type) == '2':
|
||||||
message_data['conversation_type'] = 'GroupMessage'
|
message_data['conversation_type'] = 'GroupMessage'
|
||||||
|
|
||||||
|
# Check for quoted/replied message
|
||||||
|
raw_data = incoming_message.to_dict()
|
||||||
|
text_data = raw_data.get('text', {})
|
||||||
|
if isinstance(text_data, dict) and text_data.get('isReplyMsg'):
|
||||||
|
replied_msg = text_data.get('repliedMsg', {})
|
||||||
|
if replied_msg:
|
||||||
|
quote_info = await self._parse_quoted_message(replied_msg)
|
||||||
|
message_data['QuotedMessage'] = quote_info
|
||||||
|
|
||||||
if incoming_message.message_type == 'richText':
|
if incoming_message.message_type == 'richText':
|
||||||
data = incoming_message.rich_text_content.to_dict()
|
data = incoming_message.rich_text_content.to_dict()
|
||||||
|
|
||||||
@@ -261,19 +359,52 @@ class DingTalkClient:
|
|||||||
|
|
||||||
message_data['Type'] = 'image'
|
message_data['Type'] = 'image'
|
||||||
elif incoming_message.message_type == 'audio':
|
elif incoming_message.message_type == 'audio':
|
||||||
message_data['Audio'] = await self.get_audio_url(incoming_message.to_dict()['content']['downloadCode'])
|
raw_content = incoming_message.to_dict().get('content', {})
|
||||||
|
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
|
||||||
|
if isinstance(raw_content, str):
|
||||||
|
try:
|
||||||
|
raw_content = json.loads(raw_content)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
raw_content = {}
|
||||||
|
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.info(f'DingTalk audio raw content: {json.dumps(raw_content, ensure_ascii=False)}')
|
||||||
|
|
||||||
|
# 提取钉钉自带的语音转写文字(Powered by Qwen)
|
||||||
|
recognition = raw_content.get('recognition', '')
|
||||||
|
if recognition:
|
||||||
|
message_data['Content'] = recognition
|
||||||
|
|
||||||
|
download_code = raw_content.get('downloadCode')
|
||||||
|
if download_code:
|
||||||
|
message_data['Audio'] = await self.get_audio_url(download_code)
|
||||||
|
|
||||||
message_data['Type'] = 'audio'
|
message_data['Type'] = 'audio'
|
||||||
elif incoming_message.message_type == 'file':
|
elif incoming_message.message_type == 'file':
|
||||||
down_list = incoming_message.get_down_list()
|
# 获取原始数据字典并提取嵌套的文件信息
|
||||||
if len(down_list) >= 2:
|
raw_data = incoming_message.to_dict()
|
||||||
message_data['File'] = await self.get_file_url(down_list[0])
|
file_info = raw_data.get('content', {})
|
||||||
message_data['Name'] = down_list[1]
|
|
||||||
|
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
|
||||||
|
if isinstance(file_info, str):
|
||||||
|
try:
|
||||||
|
file_info = json.loads(file_info)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
file_info = {}
|
||||||
|
|
||||||
|
download_code = file_info.get('downloadCode')
|
||||||
|
file_name = file_info.get('fileName')
|
||||||
|
|
||||||
|
if download_code and file_name:
|
||||||
|
# 转换 downloadCode 为可下载的真实 URL
|
||||||
|
message_data['File'] = await self.get_file_url(download_code)
|
||||||
|
message_data['Name'] = file_name
|
||||||
else:
|
else:
|
||||||
if self.logger:
|
if self.logger:
|
||||||
await self.logger.error(f'get_down_list() returned fewer than 2 elements: {down_list}')
|
await self.logger.error(f'Failed to extract file info from message content: {file_info}')
|
||||||
message_data['File'] = None
|
message_data['File'] = None
|
||||||
message_data['Name'] = None
|
message_data['Name'] = None
|
||||||
|
|
||||||
message_data['Type'] = 'file'
|
message_data['Type'] = 'file'
|
||||||
|
|
||||||
copy_message_data = message_data.copy()
|
copy_message_data = message_data.copy()
|
||||||
@@ -307,8 +438,13 @@ class DingTalkClient:
|
|||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.post(url, headers=headers, json=data)
|
response = await client.post(url, headers=headers, json=data)
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except Exception:
|
||||||
|
body = {'text': response.text}
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return body
|
||||||
|
raise Exception(f'Error: {response.status_code}, {body}')
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||||||
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||||||
@@ -333,17 +469,33 @@ class DingTalkClient:
|
|||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.post(url, headers=headers, json=data)
|
response = await client.post(url, headers=headers, json=data)
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except Exception:
|
||||||
|
body = {'text': response.text}
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return body
|
||||||
|
raise Exception(f'Error: {response.status_code}, {body}')
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
await self.logger.error(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
||||||
raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def create_and_card(
|
async def create_and_card(
|
||||||
self, temp_card_id: str, incoming_message: dingtalk_stream.ChatbotMessage, quote_origin: bool = False
|
self,
|
||||||
|
temp_card_id: str,
|
||||||
|
incoming_message: dingtalk_stream.ChatbotMessage,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
card_auto_layout: bool = False,
|
||||||
):
|
):
|
||||||
content_key = 'content'
|
card_data = {}
|
||||||
card_data = {content_key: ''}
|
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
|
||||||
|
card_data['content'] = ''
|
||||||
|
|
||||||
|
# 将用户的消息内容作为卡片的查询参数,方便后续处理
|
||||||
|
if incoming_message.message_type == 'text':
|
||||||
|
card_data['query'] = incoming_message.get_text_list()[0]
|
||||||
|
else:
|
||||||
|
card_data['query'] = '...'
|
||||||
|
|
||||||
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
|
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
|
||||||
# print(card_instance)
|
# print(card_instance)
|
||||||
@@ -378,4 +530,70 @@ class DingTalkClient:
|
|||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""启动 WebSocket 连接,监听消息"""
|
"""启动 WebSocket 连接,监听消息"""
|
||||||
await self.client.start()
|
self._stopped = False
|
||||||
|
self.client.pre_start()
|
||||||
|
|
||||||
|
while not self._stopped:
|
||||||
|
try:
|
||||||
|
connection = self.client.open_connection()
|
||||||
|
|
||||||
|
if not connection:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error('DingTalk: open connection failed')
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
continue
|
||||||
|
|
||||||
|
uri = '%s?ticket=%s' % (connection['endpoint'], urllib.parse.quote_plus(connection['ticket']))
|
||||||
|
async with websockets.connect(uri) as websocket:
|
||||||
|
self.client.websocket = websocket
|
||||||
|
keepalive_task = asyncio.create_task(self._keepalive(websocket))
|
||||||
|
try:
|
||||||
|
async for raw_message in websocket:
|
||||||
|
if self._stopped:
|
||||||
|
break
|
||||||
|
json_message = json.loads(raw_message)
|
||||||
|
asyncio.create_task(self.client.background_task(json_message))
|
||||||
|
finally:
|
||||||
|
keepalive_task.cancel()
|
||||||
|
try:
|
||||||
|
await keepalive_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Properly exit when task is cancelled
|
||||||
|
break
|
||||||
|
except websockets.exceptions.ConnectionClosedError as e:
|
||||||
|
if self._stopped:
|
||||||
|
break
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'DingTalk: connection closed, reconnecting... error={e}')
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
if self._stopped:
|
||||||
|
break
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.error(f'DingTalk: unknown exception, reconnecting... error={e}')
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
continue
|
||||||
|
|
||||||
|
async def _keepalive(self, ws, ping_interval=60):
|
||||||
|
"""Keep WebSocket connection alive"""
|
||||||
|
while not self._stopped:
|
||||||
|
await asyncio.sleep(ping_interval)
|
||||||
|
try:
|
||||||
|
await ws.ping()
|
||||||
|
except websockets.exceptions.ConnectionClosed:
|
||||||
|
break
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""停止 WebSocket 连接"""
|
||||||
|
self._stopped = True
|
||||||
|
# Close WebSocket connection if exists
|
||||||
|
if self.client.websocket:
|
||||||
|
try:
|
||||||
|
await self.client.websocket.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Clear message handlers to prevent stale callbacks
|
||||||
|
self._message_handlers = {'example': []}
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ class DingTalkEvent(dict):
|
|||||||
def conversation(self):
|
def conversation(self):
|
||||||
return self.get('conversation_type', '')
|
return self.get('conversation_type', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quoted_message(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get the quoted/replied message info if this is a reply message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict containing:
|
||||||
|
- message_id: The original message ID
|
||||||
|
- msg_type: The message type (text, file, picture, audio, etc.)
|
||||||
|
- content: The text content (if any)
|
||||||
|
- file_url: The file download URL (if file type)
|
||||||
|
- file_name: The file name (if file type)
|
||||||
|
- picture: The picture base64 (if picture type)
|
||||||
|
- audio: The audio base64 (if audio type)
|
||||||
|
"""
|
||||||
|
return self.get('QuotedMessage')
|
||||||
|
|
||||||
def __getattr__(self, key: str) -> Optional[Any]:
|
def __getattr__(self, key: str) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
允许通过属性访问数据中的任意字段。
|
允许通过属性访问数据中的任意字段。
|
||||||
|
|||||||
@@ -23,20 +23,34 @@ xml_template = """
|
|||||||
|
|
||||||
|
|
||||||
class OAClient:
|
class OAClient:
|
||||||
def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, logger: None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
EncodingAESKey: str,
|
||||||
|
AppID: str,
|
||||||
|
Appsecret: str,
|
||||||
|
logger: None,
|
||||||
|
unified_mode: bool = False,
|
||||||
|
api_base_url: str = 'https://api.weixin.qq.com',
|
||||||
|
):
|
||||||
self.token = token
|
self.token = token
|
||||||
self.aes = EncodingAESKey
|
self.aes = EncodingAESKey
|
||||||
self.appid = AppID
|
self.appid = AppID
|
||||||
self.appsecret = Appsecret
|
self.appsecret = Appsecret
|
||||||
self.base_url = 'https://api.weixin.qq.com'
|
self.base_url = api_base_url
|
||||||
self.access_token = ''
|
self.access_token = ''
|
||||||
|
self.unified_mode = unified_mode
|
||||||
self.app = Quart(__name__)
|
self.app = Quart(__name__)
|
||||||
|
|
||||||
|
# 只有在非统一模式下才注册独立路由
|
||||||
|
if not self.unified_mode:
|
||||||
self.app.add_url_rule(
|
self.app.add_url_rule(
|
||||||
'/callback/command',
|
'/callback/command',
|
||||||
'handle_callback',
|
'handle_callback',
|
||||||
self.handle_callback_request,
|
self.handle_callback_request,
|
||||||
methods=['GET', 'POST'],
|
methods=['GET', 'POST'],
|
||||||
)
|
)
|
||||||
|
|
||||||
self._message_handlers = {
|
self._message_handlers = {
|
||||||
'example': [],
|
'example': [],
|
||||||
}
|
}
|
||||||
@@ -46,30 +60,65 @@ class OAClient:
|
|||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
|
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||||
|
return await self._handle_callback_internal(request)
|
||||||
|
|
||||||
|
async def handle_unified_webhook(self, req):
|
||||||
|
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req: Quart Request 对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
响应数据
|
||||||
|
"""
|
||||||
|
return await self._handle_callback_internal(req)
|
||||||
|
|
||||||
|
async def _handle_callback_internal(self, req):
|
||||||
|
"""处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req: Quart Request 对象
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# 每隔100毫秒查询是否生成ai回答
|
# 每隔100毫秒查询是否生成ai回答
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
signature = request.args.get('signature', '')
|
signature = req.args.get('signature', '')
|
||||||
timestamp = request.args.get('timestamp', '')
|
timestamp = req.args.get('timestamp', '')
|
||||||
nonce = request.args.get('nonce', '')
|
nonce = req.args.get('nonce', '')
|
||||||
echostr = request.args.get('echostr', '')
|
echostr = req.args.get('echostr', '')
|
||||||
msg_signature = request.args.get('msg_signature', '')
|
msg_signature = req.args.get('msg_signature', '')
|
||||||
if msg_signature is None:
|
if msg_signature is None:
|
||||||
await self.logger.error('msg_signature不在请求体中')
|
await self.logger.error('msg_signature不在请求体中')
|
||||||
raise Exception('msg_signature不在请求体中')
|
raise Exception('msg_signature不在请求体中')
|
||||||
|
|
||||||
if request.method == 'GET':
|
if req.method == 'GET':
|
||||||
# 校验签名
|
if msg_signature:
|
||||||
|
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||||
|
ret, reply_echo = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||||
|
if ret == 0:
|
||||||
|
return reply_echo
|
||||||
|
await self.logger.error(
|
||||||
|
'OfficialAccount encrypted URL verification failed: '
|
||||||
|
f'ret={ret}, timestamp_present={bool(timestamp)}, nonce_present={bool(nonce)}, '
|
||||||
|
f'echostr_present={bool(echostr)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plaintext callback verification.
|
||||||
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
||||||
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
if check_signature == signature:
|
if check_signature == signature:
|
||||||
return echostr # 验证成功返回echostr
|
return echostr # 验证成功返回echostr
|
||||||
else:
|
else:
|
||||||
await self.logger.error('拒绝请求')
|
await self.logger.error(
|
||||||
raise Exception('拒绝请求')
|
'OfficialAccount plaintext URL verification failed: '
|
||||||
elif request.method == 'POST':
|
f'signature_present={bool(signature)}, timestamp_present={bool(timestamp)}, '
|
||||||
encryt_msg = await request.data
|
f'nonce_present={bool(nonce)}, echostr_present={bool(echostr)}'
|
||||||
|
)
|
||||||
|
return 'signature verification failed', 403
|
||||||
|
elif req.method == 'POST':
|
||||||
|
encryt_msg = await req.data
|
||||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||||
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
|
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
|
||||||
xml_msg = xml_msg.decode('utf-8')
|
xml_msg = xml_msg.decode('utf-8')
|
||||||
@@ -182,20 +231,27 @@ class OAClientForLongerResponse:
|
|||||||
Appsecret: str,
|
Appsecret: str,
|
||||||
LoadingMessage: str,
|
LoadingMessage: str,
|
||||||
logger: None,
|
logger: None,
|
||||||
|
unified_mode: bool = False,
|
||||||
|
api_base_url: str = 'https://api.weixin.qq.com',
|
||||||
):
|
):
|
||||||
self.token = token
|
self.token = token
|
||||||
self.aes = EncodingAESKey
|
self.aes = EncodingAESKey
|
||||||
self.appid = AppID
|
self.appid = AppID
|
||||||
self.appsecret = Appsecret
|
self.appsecret = Appsecret
|
||||||
self.base_url = 'https://api.weixin.qq.com'
|
self.base_url = api_base_url
|
||||||
self.access_token = ''
|
self.access_token = ''
|
||||||
|
self.unified_mode = unified_mode
|
||||||
self.app = Quart(__name__)
|
self.app = Quart(__name__)
|
||||||
|
|
||||||
|
# 只有在非统一模式下才注册独立路由
|
||||||
|
if not self.unified_mode:
|
||||||
self.app.add_url_rule(
|
self.app.add_url_rule(
|
||||||
'/callback/command',
|
'/callback/command',
|
||||||
'handle_callback',
|
'handle_callback',
|
||||||
self.handle_callback_request,
|
self.handle_callback_request,
|
||||||
methods=['GET', 'POST'],
|
methods=['GET', 'POST'],
|
||||||
)
|
)
|
||||||
|
|
||||||
self._message_handlers = {
|
self._message_handlers = {
|
||||||
'example': [],
|
'example': [],
|
||||||
}
|
}
|
||||||
@@ -206,24 +262,62 @@ class OAClientForLongerResponse:
|
|||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
|
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||||
|
return await self._handle_callback_internal(request)
|
||||||
|
|
||||||
|
async def handle_unified_webhook(self, req):
|
||||||
|
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req: Quart Request 对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
响应数据
|
||||||
|
"""
|
||||||
|
return await self._handle_callback_internal(req)
|
||||||
|
|
||||||
|
async def _handle_callback_internal(self, req):
|
||||||
|
"""处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req: Quart Request 对象
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
signature = request.args.get('signature', '')
|
signature = req.args.get('signature', '')
|
||||||
timestamp = request.args.get('timestamp', '')
|
timestamp = req.args.get('timestamp', '')
|
||||||
nonce = request.args.get('nonce', '')
|
nonce = req.args.get('nonce', '')
|
||||||
echostr = request.args.get('echostr', '')
|
echostr = req.args.get('echostr', '')
|
||||||
msg_signature = request.args.get('msg_signature', '')
|
msg_signature = req.args.get('msg_signature', '')
|
||||||
|
|
||||||
if msg_signature is None:
|
if msg_signature is None:
|
||||||
await self.logger.error('msg_signature不在请求体中')
|
await self.logger.error('msg_signature不在请求体中')
|
||||||
raise Exception('msg_signature不在请求体中')
|
raise Exception('msg_signature不在请求体中')
|
||||||
|
|
||||||
if request.method == 'GET':
|
if req.method == 'GET':
|
||||||
|
if msg_signature:
|
||||||
|
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||||
|
ret, reply_echo = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||||
|
if ret == 0:
|
||||||
|
return reply_echo
|
||||||
|
await self.logger.error(
|
||||||
|
'OfficialAccount encrypted URL verification failed: '
|
||||||
|
f'ret={ret}, timestamp_present={bool(timestamp)}, nonce_present={bool(nonce)}, '
|
||||||
|
f'echostr_present={bool(echostr)}'
|
||||||
|
)
|
||||||
|
|
||||||
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
||||||
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
||||||
return echostr if check_signature == signature else '拒绝请求'
|
if check_signature == signature:
|
||||||
|
return echostr
|
||||||
|
await self.logger.error(
|
||||||
|
'OfficialAccount plaintext URL verification failed: '
|
||||||
|
f'signature_present={bool(signature)}, timestamp_present={bool(timestamp)}, '
|
||||||
|
f'nonce_present={bool(nonce)}, echostr_present={bool(echostr)}'
|
||||||
|
)
|
||||||
|
return 'signature verification failed', 403
|
||||||
|
|
||||||
elif request.method == 'POST':
|
elif req.method == 'POST':
|
||||||
encryt_msg = await request.data
|
encryt_msg = await req.data
|
||||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||||
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
|
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
|
||||||
xml_msg = xml_msg.decode('utf-8')
|
xml_msg = xml_msg.decode('utf-8')
|
||||||
|
|||||||
3
src/langbot/libs/openclaw_weixin_api/__init__.py
Normal file
3
src/langbot/libs/openclaw_weixin_api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .client import OpenClawWeixinClient as OpenClawWeixinClient
|
||||||
|
from .types import ApiError as ApiError
|
||||||
|
from .types import LoginResult as LoginResult
|
||||||
807
src/langbot/libs/openclaw_weixin_api/client.py
Normal file
807
src/langbot/libs/openclaw_weixin_api/client.py
Normal file
@@ -0,0 +1,807 @@
|
|||||||
|
"""Async HTTP client for the OpenClaw WeChat API.
|
||||||
|
|
||||||
|
Implements the iLink Bot API protocol.
|
||||||
|
Reference: https://github.com/epiral/weixin-bot
|
||||||
|
|
||||||
|
Endpoints: getUpdates (long-poll), sendMessage, getUploadUrl, getConfig, sendTyping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import typing
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
ApiError,
|
||||||
|
CDNMedia,
|
||||||
|
FileItem,
|
||||||
|
GetConfigResponse,
|
||||||
|
GetUpdatesResponse,
|
||||||
|
GetUploadUrlResponse,
|
||||||
|
ImageItem,
|
||||||
|
LoginResult,
|
||||||
|
MessageItem,
|
||||||
|
QRCodeResponse,
|
||||||
|
QRStatusResponse,
|
||||||
|
RefMessage,
|
||||||
|
TextItem,
|
||||||
|
VideoItem,
|
||||||
|
VoiceItem,
|
||||||
|
WeixinMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger('openclaw-weixin-sdk')
|
||||||
|
|
||||||
|
DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'
|
||||||
|
CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
|
||||||
|
|
||||||
|
CHANNEL_VERSION = '1.0.0'
|
||||||
|
|
||||||
|
DEFAULT_API_TIMEOUT = 15
|
||||||
|
DEFAULT_LONG_POLL_TIMEOUT = 40
|
||||||
|
DEFAULT_CONFIG_TIMEOUT = 10
|
||||||
|
DEFAULT_QR_POLL_TIMEOUT = 35
|
||||||
|
|
||||||
|
SESSION_EXPIRED_ERRCODE = -14
|
||||||
|
|
||||||
|
DEFAULT_BOT_TYPE = '3'
|
||||||
|
|
||||||
|
# Maximum text length per message chunk (WeChat limit)
|
||||||
|
MAX_TEXT_CHUNK_SIZE = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def _random_wechat_uin() -> str:
|
||||||
|
"""Generate the X-WECHAT-UIN header: random uint32 -> decimal string -> base64."""
|
||||||
|
rand_bytes = os.urandom(4)
|
||||||
|
uint32_val = struct.unpack('>I', rand_bytes)[0]
|
||||||
|
return base64.b64encode(str(uint32_val).encode('utf-8')).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def _build_base_info() -> dict:
|
||||||
|
"""Build the base_info payload included in every API request."""
|
||||||
|
return {'channel_version': CHANNEL_VERSION}
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_text(text: str, max_size: int = MAX_TEXT_CHUNK_SIZE) -> list[str]:
|
||||||
|
"""Split long text into chunks that fit within WeChat's message size limit."""
|
||||||
|
if len(text) <= max_size:
|
||||||
|
return [text]
|
||||||
|
chunks = []
|
||||||
|
while text:
|
||||||
|
chunks.append(text[:max_size])
|
||||||
|
text = text[max_size:]
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawWeixinClient:
|
||||||
|
"""Async client for the OpenClaw WeChat HTTP JSON API."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, token: str):
|
||||||
|
self.base_url = base_url.rstrip('/')
|
||||||
|
self.token = token
|
||||||
|
self._session: Optional[aiohttp.ClientSession] = None
|
||||||
|
|
||||||
|
async def _get_session(self) -> aiohttp.ClientSession:
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
self._session = aiohttp.ClientSession()
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
def _build_headers(self) -> dict[str, str]:
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'AuthorizationType': 'ilink_bot_token',
|
||||||
|
'X-WECHAT-UIN': _random_wechat_uin(),
|
||||||
|
}
|
||||||
|
if self.token:
|
||||||
|
headers['Authorization'] = f'Bearer {self.token}'
|
||||||
|
return headers
|
||||||
|
|
||||||
|
async def _post(self, endpoint: str, payload: dict, timeout: float = DEFAULT_API_TIMEOUT) -> dict:
|
||||||
|
"""Make a POST request and return the JSON response.
|
||||||
|
|
||||||
|
Raises ApiError on HTTP errors or when the response contains a non-zero errcode.
|
||||||
|
"""
|
||||||
|
payload['base_info'] = _build_base_info()
|
||||||
|
|
||||||
|
session = await self._get_session()
|
||||||
|
url = f'{self.base_url}/{endpoint}'
|
||||||
|
headers = self._build_headers()
|
||||||
|
|
||||||
|
async with session.post(
|
||||||
|
url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout)
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise ApiError(
|
||||||
|
f'OpenClaw API error {resp.status}: {text}',
|
||||||
|
status=resp.status,
|
||||||
|
)
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
|
|
||||||
|
# Check for application-level errors in the response body
|
||||||
|
errcode = data.get('errcode') or data.get('ret')
|
||||||
|
if errcode and errcode != 0:
|
||||||
|
raise ApiError(
|
||||||
|
data.get('errmsg') or f'API errcode {errcode}',
|
||||||
|
status=200,
|
||||||
|
code=errcode,
|
||||||
|
payload=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_updates(
|
||||||
|
self, get_updates_buf: str = '', timeout: float = DEFAULT_LONG_POLL_TIMEOUT
|
||||||
|
) -> GetUpdatesResponse:
|
||||||
|
"""Long-poll for new messages.
|
||||||
|
|
||||||
|
Note: This method does NOT raise ApiError for errcode responses —
|
||||||
|
it returns them in the GetUpdatesResponse so the caller can handle
|
||||||
|
session expiry and other errors with full context.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Bypass the errcode check in _post since get_updates needs
|
||||||
|
# to return error info (e.g. session expired) to the caller.
|
||||||
|
payload: dict = {'get_updates_buf': get_updates_buf}
|
||||||
|
payload['base_info'] = _build_base_info()
|
||||||
|
|
||||||
|
session = await self._get_session()
|
||||||
|
url = f'{self.base_url}/ilink/bot/getupdates'
|
||||||
|
headers = self._build_headers()
|
||||||
|
|
||||||
|
async with session.post(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise ApiError(
|
||||||
|
f'OpenClaw API error {resp.status}: {text}',
|
||||||
|
status=resp.status,
|
||||||
|
)
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
|
|
||||||
|
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
|
||||||
|
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
|
||||||
|
except ApiError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if 'timeout' in str(e).lower():
|
||||||
|
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return _parse_get_updates_response(data)
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
self,
|
||||||
|
to_user_id: str,
|
||||||
|
item_list: list[MessageItem],
|
||||||
|
context_token: str = '',
|
||||||
|
) -> None:
|
||||||
|
"""Send a message to a user."""
|
||||||
|
items_payload = [_message_item_to_dict(item) for item in item_list]
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'msg': {
|
||||||
|
'from_user_id': '',
|
||||||
|
'to_user_id': to_user_id,
|
||||||
|
'client_id': f'langbot-{uuid.uuid4().hex[:16]}',
|
||||||
|
'message_type': WeixinMessage.TYPE_BOT,
|
||||||
|
'message_state': WeixinMessage.STATE_FINISH,
|
||||||
|
'item_list': items_payload,
|
||||||
|
'context_token': context_token or None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self._post('ilink/bot/sendmessage', payload)
|
||||||
|
|
||||||
|
async def send_text(self, to_user_id: str, text: str, context_token: str = '') -> None:
|
||||||
|
"""Send a plain text message, automatically chunking if too long."""
|
||||||
|
chunks = _chunk_text(text)
|
||||||
|
for chunk in chunks:
|
||||||
|
item = MessageItem(type=MessageItem.TEXT, text_item=TextItem(text=chunk))
|
||||||
|
await self.send_message(to_user_id, [item], context_token)
|
||||||
|
|
||||||
|
async def get_config(self, ilink_user_id: str, context_token: str = '') -> GetConfigResponse:
|
||||||
|
"""Get bot config including typing_ticket."""
|
||||||
|
data = await self._post(
|
||||||
|
'ilink/bot/getconfig',
|
||||||
|
{'ilink_user_id': ilink_user_id, 'context_token': context_token or None},
|
||||||
|
timeout=DEFAULT_CONFIG_TIMEOUT,
|
||||||
|
)
|
||||||
|
return GetConfigResponse(
|
||||||
|
ret=data.get('ret'),
|
||||||
|
errmsg=data.get('errmsg'),
|
||||||
|
typing_ticket=data.get('typing_ticket'),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_typing(self, ilink_user_id: str, typing_ticket: str, status: int = 1) -> None:
|
||||||
|
"""Send typing indicator. status: 1=typing, 2=cancel."""
|
||||||
|
await self._post(
|
||||||
|
'ilink/bot/sendtyping',
|
||||||
|
{
|
||||||
|
'ilink_user_id': ilink_user_id,
|
||||||
|
'typing_ticket': typing_ticket,
|
||||||
|
'status': status,
|
||||||
|
},
|
||||||
|
timeout=DEFAULT_CONFIG_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop_typing(self, ilink_user_id: str, typing_ticket: str) -> None:
|
||||||
|
"""Cancel the typing indicator for a user."""
|
||||||
|
await self.send_typing(ilink_user_id, typing_ticket, status=2)
|
||||||
|
|
||||||
|
async def download_media(
|
||||||
|
self,
|
||||||
|
media: CDNMedia,
|
||||||
|
) -> bytes:
|
||||||
|
"""Download and decrypt a file from the WeChat CDN.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
media: CDNMedia object with encrypt_query_param and aes_key.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decrypted file bytes.
|
||||||
|
"""
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
from cryptography.hazmat.primitives.padding import PKCS7
|
||||||
|
|
||||||
|
if not media.encrypt_query_param:
|
||||||
|
raise ApiError('CDN media has no encrypt_query_param', status=0)
|
||||||
|
if not media.aes_key:
|
||||||
|
raise ApiError('CDN media has no aes_key', status=0)
|
||||||
|
|
||||||
|
# Derive 16-byte AES key
|
||||||
|
# aes_key is base64-encoded; the decoded content may be:
|
||||||
|
# - raw 16 bytes (direct AES key)
|
||||||
|
# - 32-char hex string (decode hex to get 16 bytes)
|
||||||
|
raw = base64.b64decode(media.aes_key)
|
||||||
|
if len(raw) == 16:
|
||||||
|
aes_key = raw
|
||||||
|
elif len(raw) == 32:
|
||||||
|
# Hex-encoded 16-byte key
|
||||||
|
aes_key = bytes.fromhex(raw.decode('utf-8'))
|
||||||
|
else:
|
||||||
|
raise ApiError(f'Invalid AES key length: {len(raw)} (expected 16 or 32)', status=0)
|
||||||
|
|
||||||
|
# Download encrypted bytes from CDN
|
||||||
|
session = await self._get_session()
|
||||||
|
cdn_url = f'{CDN_BASE_URL}/download?encrypted_query_param={quote(media.encrypt_query_param, safe="")}'
|
||||||
|
|
||||||
|
async with session.get(cdn_url, timeout=aiohttp.ClientTimeout(total=120)) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise ApiError(f'CDN download failed: {resp.status} {text}', status=resp.status)
|
||||||
|
encrypted = await resp.read()
|
||||||
|
|
||||||
|
# Decrypt AES-128-ECB with PKCS7 padding
|
||||||
|
cipher = Cipher(algorithms.AES(aes_key), modes.ECB())
|
||||||
|
decryptor = cipher.decryptor()
|
||||||
|
padded = decryptor.update(encrypted) + decryptor.finalize()
|
||||||
|
|
||||||
|
unpadder = PKCS7(128).unpadder()
|
||||||
|
return unpadder.update(padded) + unpadder.finalize()
|
||||||
|
|
||||||
|
async def upload_media(
|
||||||
|
self,
|
||||||
|
file_bytes: bytes,
|
||||||
|
to_user_id: str,
|
||||||
|
media_type: int,
|
||||||
|
) -> CDNMedia:
|
||||||
|
"""Encrypt and upload media to WeChat CDN.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_bytes: Raw file bytes to upload.
|
||||||
|
to_user_id: Recipient user ID.
|
||||||
|
media_type: 1=IMAGE, 2=VIDEO, 3=FILE, 4=VOICE.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CDNMedia with encrypt_query_param and aes_key for use in sendMessage.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
from cryptography.hazmat.primitives.padding import PKCS7
|
||||||
|
|
||||||
|
# 1. Generate random 16-byte AES key
|
||||||
|
raw_key = os.urandom(16)
|
||||||
|
aes_key_hex = raw_key.hex() # 32-char hex string
|
||||||
|
|
||||||
|
# 2. Encode key for CDNMedia: base64(hex_string) — same for all media types
|
||||||
|
# Matches official SDK: Buffer.from(aeskey_hex).toString("base64")
|
||||||
|
encoded_key = base64.b64encode(aes_key_hex.encode('utf-8')).decode('utf-8')
|
||||||
|
|
||||||
|
# 3. Encrypt file with AES-128-ECB + PKCS7
|
||||||
|
padder = PKCS7(128).padder()
|
||||||
|
padded = padder.update(file_bytes) + padder.finalize()
|
||||||
|
cipher = Cipher(algorithms.AES(raw_key), modes.ECB())
|
||||||
|
encryptor = cipher.encryptor()
|
||||||
|
encrypted = encryptor.update(padded) + encryptor.finalize()
|
||||||
|
|
||||||
|
# 4. Get upload URL
|
||||||
|
raw_md5 = hashlib.md5(file_bytes).hexdigest()
|
||||||
|
filekey = os.urandom(16).hex() # 32-char hex, matches official SDK
|
||||||
|
|
||||||
|
upload_resp = await self.get_upload_url(
|
||||||
|
filekey=filekey,
|
||||||
|
media_type=media_type,
|
||||||
|
to_user_id=to_user_id,
|
||||||
|
rawsize=len(file_bytes),
|
||||||
|
rawfilemd5=raw_md5,
|
||||||
|
filesize=len(encrypted),
|
||||||
|
aeskey=aes_key_hex, # hex string, as expected by the API
|
||||||
|
)
|
||||||
|
|
||||||
|
if not upload_resp.upload_param:
|
||||||
|
raise ApiError('Failed to get upload URL', status=0)
|
||||||
|
|
||||||
|
# 5. Upload to CDN
|
||||||
|
# upload_param is an opaque token from the server — pass it as-is
|
||||||
|
session = await self._get_session()
|
||||||
|
cdn_url = f'{CDN_BASE_URL}/upload?encrypted_query_param={quote(upload_resp.upload_param, safe="")}&filekey={quote(filekey, safe="")}'
|
||||||
|
logger.debug(
|
||||||
|
'CDN upload: url=%s raw_size=%d encrypted_size=%d md5=%s aeskey=%s',
|
||||||
|
cdn_url,
|
||||||
|
len(file_bytes),
|
||||||
|
len(encrypted),
|
||||||
|
raw_md5,
|
||||||
|
encoded_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with session.post(
|
||||||
|
cdn_url,
|
||||||
|
data=encrypted,
|
||||||
|
headers={'Content-Type': 'application/octet-stream'},
|
||||||
|
timeout=aiohttp.ClientTimeout(total=120),
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
logger.error('CDN upload failed: status=%d url=%s body=%s', resp.status, cdn_url, text[:500])
|
||||||
|
raise ApiError(f'CDN upload failed: {resp.status} {text}', status=resp.status)
|
||||||
|
download_param = resp.headers.get('x-encrypted-param', '')
|
||||||
|
|
||||||
|
if not download_param:
|
||||||
|
raise ApiError('CDN upload succeeded but no x-encrypted-param returned', status=0)
|
||||||
|
|
||||||
|
return CDNMedia(
|
||||||
|
encrypt_query_param=download_param,
|
||||||
|
aes_key=encoded_key,
|
||||||
|
encrypt_type=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_image(
|
||||||
|
self,
|
||||||
|
to_user_id: str,
|
||||||
|
image_bytes: bytes,
|
||||||
|
context_token: str = '',
|
||||||
|
) -> None:
|
||||||
|
"""Upload an image to CDN and send it."""
|
||||||
|
media = await self.upload_media(image_bytes, to_user_id, media_type=1)
|
||||||
|
item = MessageItem(
|
||||||
|
type=MessageItem.IMAGE,
|
||||||
|
image_item=ImageItem(
|
||||||
|
media=media,
|
||||||
|
aeskey=media.aes_key,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await self.send_message(to_user_id, [item], context_token)
|
||||||
|
|
||||||
|
async def send_file(
|
||||||
|
self,
|
||||||
|
to_user_id: str,
|
||||||
|
file_bytes: bytes,
|
||||||
|
file_name: str,
|
||||||
|
context_token: str = '',
|
||||||
|
) -> None:
|
||||||
|
"""Upload a file to CDN and send it."""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
media = await self.upload_media(file_bytes, to_user_id, media_type=3)
|
||||||
|
item = MessageItem(
|
||||||
|
type=MessageItem.FILE,
|
||||||
|
file_item=FileItem(
|
||||||
|
media=media,
|
||||||
|
file_name=file_name,
|
||||||
|
md5=hashlib.md5(file_bytes).hexdigest(),
|
||||||
|
len=str(len(file_bytes)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await self.send_message(to_user_id, [item], context_token)
|
||||||
|
|
||||||
|
async def send_voice(
|
||||||
|
self,
|
||||||
|
to_user_id: str,
|
||||||
|
voice_bytes: bytes,
|
||||||
|
playtime: int = 0,
|
||||||
|
context_token: str = '',
|
||||||
|
) -> None:
|
||||||
|
"""Upload a voice message to CDN and send it."""
|
||||||
|
media = await self.upload_media(voice_bytes, to_user_id, media_type=4)
|
||||||
|
item = MessageItem(
|
||||||
|
type=MessageItem.VOICE,
|
||||||
|
voice_item=VoiceItem(
|
||||||
|
media=media,
|
||||||
|
playtime=playtime,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await self.send_message(to_user_id, [item], context_token)
|
||||||
|
|
||||||
|
async def get_upload_url(
|
||||||
|
self,
|
||||||
|
filekey: str,
|
||||||
|
media_type: int,
|
||||||
|
to_user_id: str,
|
||||||
|
rawsize: int,
|
||||||
|
rawfilemd5: str,
|
||||||
|
filesize: int,
|
||||||
|
thumb_rawsize: Optional[int] = None,
|
||||||
|
thumb_rawfilemd5: Optional[str] = None,
|
||||||
|
thumb_filesize: Optional[int] = None,
|
||||||
|
aeskey: Optional[str] = None,
|
||||||
|
) -> GetUploadUrlResponse:
|
||||||
|
"""Get a pre-signed CDN upload URL."""
|
||||||
|
payload: dict = {
|
||||||
|
'filekey': filekey,
|
||||||
|
'media_type': media_type,
|
||||||
|
'to_user_id': to_user_id,
|
||||||
|
'rawsize': rawsize,
|
||||||
|
'rawfilemd5': rawfilemd5,
|
||||||
|
'filesize': filesize,
|
||||||
|
'no_need_thumb': True,
|
||||||
|
}
|
||||||
|
if thumb_rawsize is not None:
|
||||||
|
payload['thumb_rawsize'] = thumb_rawsize
|
||||||
|
if thumb_rawfilemd5 is not None:
|
||||||
|
payload['thumb_rawfilemd5'] = thumb_rawfilemd5
|
||||||
|
if thumb_filesize is not None:
|
||||||
|
payload['thumb_filesize'] = thumb_filesize
|
||||||
|
if aeskey is not None:
|
||||||
|
payload['aeskey'] = aeskey
|
||||||
|
|
||||||
|
data = await self._post('ilink/bot/getuploadurl', payload)
|
||||||
|
logger.debug('get_upload_url response: %s', data)
|
||||||
|
return GetUploadUrlResponse(
|
||||||
|
upload_param=data.get('upload_param'),
|
||||||
|
thumb_upload_param=data.get('thumb_upload_param'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# QR Code Login
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def fetch_qrcode(self, bot_type: str = DEFAULT_BOT_TYPE) -> QRCodeResponse:
|
||||||
|
"""Fetch a QR code for WeChat login authorization (GET, no auth needed)."""
|
||||||
|
session = await self._get_session()
|
||||||
|
url = f'{self.base_url}/ilink/bot/get_bot_qrcode?bot_type={bot_type}'
|
||||||
|
|
||||||
|
async with session.get(url, timeout=aiohttp.ClientTimeout(total=DEFAULT_API_TIMEOUT)) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise ApiError(
|
||||||
|
f'Failed to fetch QR code: {resp.status} {text}',
|
||||||
|
status=resp.status,
|
||||||
|
)
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'fetch_qrcode response: qrcode=%s, img=%s', data.get('qrcode'), bool(data.get('qrcode_img_content'))
|
||||||
|
)
|
||||||
|
|
||||||
|
return QRCodeResponse(
|
||||||
|
qrcode=data.get('qrcode'),
|
||||||
|
qrcode_img_content=data.get('qrcode_img_content'),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _fetch_qr_image_base64(self, url: str) -> str:
|
||||||
|
"""Generate a QR code image from the URL and return a data URI string.
|
||||||
|
|
||||||
|
The qrcode_img_content URL points to an HTML page (not a raw image),
|
||||||
|
so we generate the QR code locally using the qrcode library.
|
||||||
|
"""
|
||||||
|
import qrcode
|
||||||
|
|
||||||
|
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L)
|
||||||
|
qr.add_data(url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
img = qr.make_image(fill_color='black', back_color='white')
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format='PNG')
|
||||||
|
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
return f'data:image/png;base64,{b64}'
|
||||||
|
|
||||||
|
async def poll_qrcode_status(self, qrcode: str) -> QRStatusResponse:
|
||||||
|
"""Long-poll the QR code scan status (GET with iLink-App-ClientVersion header)."""
|
||||||
|
session = await self._get_session()
|
||||||
|
url = f'{self.base_url}/ilink/bot/get_qrcode_status?qrcode={quote(qrcode, safe="")}'
|
||||||
|
headers = {'iLink-App-ClientVersion': '1'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session.get(
|
||||||
|
url, headers=headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_QR_POLL_TIMEOUT)
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise ApiError(
|
||||||
|
f'Failed to poll QR status: {resp.status} {text}',
|
||||||
|
status=resp.status,
|
||||||
|
)
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
|
logger.debug('QR status poll response: %s', data)
|
||||||
|
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
|
||||||
|
return QRStatusResponse(status='wait')
|
||||||
|
|
||||||
|
return QRStatusResponse(
|
||||||
|
status=data.get('status'),
|
||||||
|
bot_token=data.get('bot_token'),
|
||||||
|
ilink_bot_id=data.get('ilink_bot_id'),
|
||||||
|
baseurl=data.get('baseurl'),
|
||||||
|
ilink_user_id=data.get('ilink_user_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def login(
|
||||||
|
self,
|
||||||
|
max_retries: int = 5,
|
||||||
|
poll_timeout_ms: int = 480_000,
|
||||||
|
on_qrcode: Optional[typing.Callable[[str, str], typing.Any]] = None,
|
||||||
|
on_status: Optional[typing.Callable[[str], typing.Any]] = None,
|
||||||
|
) -> LoginResult:
|
||||||
|
"""Complete QR code login flow with auto-retry on expiry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_retries: Max number of QR code refreshes on expiry.
|
||||||
|
poll_timeout_ms: Timeout per QR code in milliseconds.
|
||||||
|
on_qrcode: Callback(qr_image_base64, qr_url) called each time a
|
||||||
|
new QR code is fetched. Use this to display the QR code.
|
||||||
|
on_status: Callback(status_str) called on each status poll change.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LoginResult with token, base_url, and account_id.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ApiError: On unrecoverable API errors.
|
||||||
|
Exception: If all retries are exhausted.
|
||||||
|
"""
|
||||||
|
last_qr_base64: Optional[str] = None
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
qr_resp = await self.fetch_qrcode()
|
||||||
|
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
|
||||||
|
raise ApiError('Failed to get QR code from server', status=0)
|
||||||
|
|
||||||
|
# Convert QR image to base64 and notify caller
|
||||||
|
last_qr_base64 = await self._fetch_qr_image_base64(qr_resp.qrcode_img_content)
|
||||||
|
if on_qrcode:
|
||||||
|
try:
|
||||||
|
result = on_qrcode(last_qr_base64, qr_resp.qrcode_img_content)
|
||||||
|
if asyncio.iscoroutine(result) or asyncio.isfuture(result):
|
||||||
|
await result
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('on_qrcode callback error: %s', e)
|
||||||
|
|
||||||
|
# Poll until confirmed / expired / timeout
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
deadline = loop.time() + poll_timeout_ms / 1000.0
|
||||||
|
|
||||||
|
while loop.time() < deadline:
|
||||||
|
try:
|
||||||
|
status_resp = await self.poll_qrcode_status(qr_resp.qrcode)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error polling QR status: %s', e)
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if on_status:
|
||||||
|
try:
|
||||||
|
cb_result = on_status(status_resp.status or 'unknown')
|
||||||
|
if asyncio.iscoroutine(cb_result) or asyncio.isfuture(cb_result):
|
||||||
|
await cb_result
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('on_status callback error: %s', e)
|
||||||
|
|
||||||
|
if status_resp.status == 'confirmed' and status_resp.bot_token:
|
||||||
|
new_base_url = status_resp.baseurl or self.base_url
|
||||||
|
# Update this client instance as well
|
||||||
|
self.token = status_resp.bot_token
|
||||||
|
self.base_url = new_base_url.rstrip('/')
|
||||||
|
return LoginResult(
|
||||||
|
token=status_resp.bot_token,
|
||||||
|
base_url=new_base_url,
|
||||||
|
account_id=status_resp.ilink_bot_id or '',
|
||||||
|
qr_image_base64=last_qr_base64,
|
||||||
|
)
|
||||||
|
|
||||||
|
if status_resp.status == 'expired':
|
||||||
|
break # retry with a new QR code
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
else:
|
||||||
|
# While-loop ended without break → poll timeout, treat as expired
|
||||||
|
pass
|
||||||
|
|
||||||
|
remaining = max_retries - attempt - 1
|
||||||
|
if remaining > 0:
|
||||||
|
logger.info('QR code expired, refreshing... (%d retries left)', remaining)
|
||||||
|
else:
|
||||||
|
raise ApiError('QR code login failed: max retries exceeded', status=0)
|
||||||
|
|
||||||
|
# Should not reach here, but just in case
|
||||||
|
raise ApiError('QR code login failed', status=0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parsing helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cdn_media(data: Optional[dict]) -> Optional[CDNMedia]:
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
return CDNMedia(
|
||||||
|
encrypt_query_param=data.get('encrypt_query_param'),
|
||||||
|
aes_key=data.get('aes_key'),
|
||||||
|
encrypt_type=data.get('encrypt_type'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_message_item(data: dict) -> MessageItem:
|
||||||
|
item = MessageItem(
|
||||||
|
type=data.get('type'),
|
||||||
|
create_time_ms=data.get('create_time_ms'),
|
||||||
|
update_time_ms=data.get('update_time_ms'),
|
||||||
|
is_completed=data.get('is_completed'),
|
||||||
|
msg_id=data.get('msg_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get('text_item'):
|
||||||
|
item.text_item = TextItem(text=data['text_item'].get('text'))
|
||||||
|
|
||||||
|
if data.get('image_item'):
|
||||||
|
img = data['image_item']
|
||||||
|
item.image_item = ImageItem(
|
||||||
|
media=_parse_cdn_media(img.get('media')),
|
||||||
|
thumb_media=_parse_cdn_media(img.get('thumb_media')),
|
||||||
|
aeskey=img.get('aeskey'),
|
||||||
|
url=img.get('url'),
|
||||||
|
mid_size=img.get('mid_size'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get('voice_item'):
|
||||||
|
v = data['voice_item']
|
||||||
|
item.voice_item = VoiceItem(
|
||||||
|
media=_parse_cdn_media(v.get('media')),
|
||||||
|
encode_type=v.get('encode_type'),
|
||||||
|
playtime=v.get('playtime'),
|
||||||
|
text=v.get('text'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get('file_item'):
|
||||||
|
f = data['file_item']
|
||||||
|
item.file_item = FileItem(
|
||||||
|
media=_parse_cdn_media(f.get('media')),
|
||||||
|
file_name=f.get('file_name'),
|
||||||
|
md5=f.get('md5'),
|
||||||
|
len=f.get('len'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get('video_item'):
|
||||||
|
vid = data['video_item']
|
||||||
|
item.video_item = VideoItem(
|
||||||
|
media=_parse_cdn_media(vid.get('media')),
|
||||||
|
video_size=vid.get('video_size'),
|
||||||
|
play_length=vid.get('play_length'),
|
||||||
|
video_md5=vid.get('video_md5'),
|
||||||
|
thumb_media=_parse_cdn_media(vid.get('thumb_media')),
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get('ref_msg'):
|
||||||
|
ref = data['ref_msg']
|
||||||
|
item.ref_msg = RefMessage(
|
||||||
|
title=ref.get('title'),
|
||||||
|
message_item=_parse_message_item(ref['message_item']) if ref.get('message_item') else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_weixin_message(data: dict) -> WeixinMessage:
|
||||||
|
msg = WeixinMessage(
|
||||||
|
seq=data.get('seq'),
|
||||||
|
message_id=data.get('message_id'),
|
||||||
|
from_user_id=data.get('from_user_id'),
|
||||||
|
to_user_id=data.get('to_user_id'),
|
||||||
|
client_id=data.get('client_id'),
|
||||||
|
create_time_ms=data.get('create_time_ms'),
|
||||||
|
session_id=data.get('session_id'),
|
||||||
|
group_id=data.get('group_id'),
|
||||||
|
message_type=data.get('message_type'),
|
||||||
|
message_state=data.get('message_state'),
|
||||||
|
context_token=data.get('context_token'),
|
||||||
|
)
|
||||||
|
if data.get('item_list'):
|
||||||
|
msg.item_list = [_parse_message_item(item) for item in data['item_list']]
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_get_updates_response(data: dict) -> GetUpdatesResponse:
|
||||||
|
resp = GetUpdatesResponse(
|
||||||
|
ret=data.get('ret'),
|
||||||
|
errcode=data.get('errcode'),
|
||||||
|
errmsg=data.get('errmsg'),
|
||||||
|
get_updates_buf=data.get('get_updates_buf'),
|
||||||
|
longpolling_timeout_ms=data.get('longpolling_timeout_ms'),
|
||||||
|
)
|
||||||
|
if data.get('msgs'):
|
||||||
|
resp.msgs = [_parse_weixin_message(m) for m in data['msgs']]
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _cdn_media_to_dict(media: Optional[CDNMedia]) -> Optional[dict]:
|
||||||
|
if not media:
|
||||||
|
return None
|
||||||
|
d: dict = {}
|
||||||
|
if media.encrypt_query_param is not None:
|
||||||
|
d['encrypt_query_param'] = media.encrypt_query_param
|
||||||
|
if media.aes_key is not None:
|
||||||
|
d['aes_key'] = media.aes_key
|
||||||
|
if media.encrypt_type is not None:
|
||||||
|
d['encrypt_type'] = media.encrypt_type
|
||||||
|
return d or None
|
||||||
|
|
||||||
|
|
||||||
|
def _message_item_to_dict(item: MessageItem) -> dict:
|
||||||
|
d: dict = {'type': item.type}
|
||||||
|
|
||||||
|
if item.text_item:
|
||||||
|
d['text_item'] = {'text': item.text_item.text}
|
||||||
|
|
||||||
|
if item.image_item:
|
||||||
|
img_d: dict = {}
|
||||||
|
if item.image_item.media:
|
||||||
|
img_d['media'] = _cdn_media_to_dict(item.image_item.media)
|
||||||
|
if item.image_item.mid_size is not None:
|
||||||
|
img_d['mid_size'] = item.image_item.mid_size
|
||||||
|
d['image_item'] = img_d
|
||||||
|
|
||||||
|
if item.voice_item:
|
||||||
|
voice_d: dict = {}
|
||||||
|
if item.voice_item.media:
|
||||||
|
voice_d['media'] = _cdn_media_to_dict(item.voice_item.media)
|
||||||
|
if item.voice_item.playtime is not None:
|
||||||
|
voice_d['playtime'] = item.voice_item.playtime
|
||||||
|
d['voice_item'] = voice_d
|
||||||
|
|
||||||
|
if item.file_item:
|
||||||
|
file_d: dict = {}
|
||||||
|
if item.file_item.media:
|
||||||
|
file_d['media'] = _cdn_media_to_dict(item.file_item.media)
|
||||||
|
if item.file_item.file_name:
|
||||||
|
file_d['file_name'] = item.file_item.file_name
|
||||||
|
if item.file_item.len:
|
||||||
|
file_d['len'] = item.file_item.len
|
||||||
|
d['file_item'] = file_d
|
||||||
|
|
||||||
|
if item.video_item:
|
||||||
|
vid_d: dict = {}
|
||||||
|
if item.video_item.media:
|
||||||
|
vid_d['media'] = _cdn_media_to_dict(item.video_item.media)
|
||||||
|
if item.video_item.video_size is not None:
|
||||||
|
vid_d['video_size'] = item.video_item.video_size
|
||||||
|
d['video_item'] = vid_d
|
||||||
|
|
||||||
|
return d
|
||||||
200
src/langbot/libs/openclaw_weixin_api/types.py
Normal file
200
src/langbot/libs/openclaw_weixin_api/types.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""Type definitions for the OpenClaw WeChat API, mirroring the upstream protocol."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
SESSION_EXPIRED_ERRCODE = -14
|
||||||
|
|
||||||
|
|
||||||
|
class ApiError(Exception):
|
||||||
|
"""Structured error raised by the OpenClaw WeChat API."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
status: int = 0,
|
||||||
|
code: int | None = None,
|
||||||
|
payload: Any = None,
|
||||||
|
):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
self.code = code
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_session_expired(self) -> bool:
|
||||||
|
return self.code == SESSION_EXPIRED_ERRCODE
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CDNMedia:
|
||||||
|
encrypt_query_param: Optional[str] = None
|
||||||
|
aes_key: Optional[str] = None
|
||||||
|
encrypt_type: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TextItem:
|
||||||
|
text: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImageItem:
|
||||||
|
media: Optional[CDNMedia] = None
|
||||||
|
thumb_media: Optional[CDNMedia] = None
|
||||||
|
aeskey: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
mid_size: Optional[int] = None
|
||||||
|
thumb_size: Optional[int] = None
|
||||||
|
thumb_height: Optional[int] = None
|
||||||
|
thumb_width: Optional[int] = None
|
||||||
|
hd_size: Optional[int] = None
|
||||||
|
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VoiceItem:
|
||||||
|
media: Optional[CDNMedia] = None
|
||||||
|
encode_type: Optional[int] = None
|
||||||
|
bits_per_sample: Optional[int] = None
|
||||||
|
sample_rate: Optional[int] = None
|
||||||
|
playtime: Optional[int] = None
|
||||||
|
text: Optional[str] = None
|
||||||
|
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileItem:
|
||||||
|
media: Optional[CDNMedia] = None
|
||||||
|
file_name: Optional[str] = None
|
||||||
|
md5: Optional[str] = None
|
||||||
|
len: Optional[str] = None
|
||||||
|
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VideoItem:
|
||||||
|
media: Optional[CDNMedia] = None
|
||||||
|
video_size: Optional[int] = None
|
||||||
|
play_length: Optional[int] = None
|
||||||
|
video_md5: Optional[str] = None
|
||||||
|
thumb_media: Optional[CDNMedia] = None
|
||||||
|
thumb_size: Optional[int] = None
|
||||||
|
thumb_height: Optional[int] = None
|
||||||
|
thumb_width: Optional[int] = None
|
||||||
|
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RefMessage:
|
||||||
|
message_item: Optional[MessageItem] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MessageItem:
|
||||||
|
"""A single content item inside a WeixinMessage."""
|
||||||
|
|
||||||
|
# Item types
|
||||||
|
NONE = 0
|
||||||
|
TEXT = 1
|
||||||
|
IMAGE = 2
|
||||||
|
VOICE = 3
|
||||||
|
FILE = 4
|
||||||
|
VIDEO = 5
|
||||||
|
|
||||||
|
type: Optional[int] = None
|
||||||
|
create_time_ms: Optional[int] = None
|
||||||
|
update_time_ms: Optional[int] = None
|
||||||
|
is_completed: Optional[bool] = None
|
||||||
|
msg_id: Optional[str] = None
|
||||||
|
ref_msg: Optional[RefMessage] = None
|
||||||
|
text_item: Optional[TextItem] = None
|
||||||
|
image_item: Optional[ImageItem] = None
|
||||||
|
voice_item: Optional[VoiceItem] = None
|
||||||
|
file_item: Optional[FileItem] = None
|
||||||
|
video_item: Optional[VideoItem] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WeixinMessage:
|
||||||
|
"""Unified message from getUpdates or for sendMessage."""
|
||||||
|
|
||||||
|
# Message types
|
||||||
|
TYPE_USER = 1
|
||||||
|
TYPE_BOT = 2
|
||||||
|
|
||||||
|
# Message states
|
||||||
|
STATE_NEW = 0
|
||||||
|
STATE_GENERATING = 1
|
||||||
|
STATE_FINISH = 2
|
||||||
|
|
||||||
|
seq: Optional[int] = None
|
||||||
|
message_id: Optional[int] = None
|
||||||
|
from_user_id: Optional[str] = None
|
||||||
|
to_user_id: Optional[str] = None
|
||||||
|
client_id: Optional[str] = None
|
||||||
|
create_time_ms: Optional[int] = None
|
||||||
|
update_time_ms: Optional[int] = None
|
||||||
|
delete_time_ms: Optional[int] = None
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
group_id: Optional[str] = None
|
||||||
|
message_type: Optional[int] = None
|
||||||
|
message_state: Optional[int] = None
|
||||||
|
item_list: Optional[list[MessageItem]] = None
|
||||||
|
context_token: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GetUpdatesResponse:
|
||||||
|
ret: Optional[int] = None
|
||||||
|
errcode: Optional[int] = None
|
||||||
|
errmsg: Optional[str] = None
|
||||||
|
msgs: list[WeixinMessage] = field(default_factory=list)
|
||||||
|
get_updates_buf: Optional[str] = None
|
||||||
|
longpolling_timeout_ms: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GetConfigResponse:
|
||||||
|
ret: Optional[int] = None
|
||||||
|
errmsg: Optional[str] = None
|
||||||
|
typing_ticket: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GetUploadUrlResponse:
|
||||||
|
upload_param: Optional[str] = None
|
||||||
|
thumb_upload_param: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QRCodeResponse:
|
||||||
|
"""Response from get_bot_qrcode endpoint."""
|
||||||
|
|
||||||
|
qrcode: Optional[str] = None
|
||||||
|
qrcode_img_content: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QRStatusResponse:
|
||||||
|
"""Response from get_qrcode_status endpoint."""
|
||||||
|
|
||||||
|
status: Optional[str] = None # "wait" | "scaned" | "confirmed" | "expired"
|
||||||
|
bot_token: Optional[str] = None
|
||||||
|
ilink_bot_id: Optional[str] = None
|
||||||
|
baseurl: Optional[str] = None
|
||||||
|
ilink_user_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LoginResult:
|
||||||
|
"""Result returned by the login flow."""
|
||||||
|
|
||||||
|
token: str
|
||||||
|
base_url: str
|
||||||
|
account_id: str
|
||||||
|
qr_image_base64: Optional[str] = None # data URI of the last QR code shown
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
import asyncio
|
||||||
from quart import request
|
from quart import request
|
||||||
import httpx
|
import httpx
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
from typing import Callable, Dict, Any
|
from typing import Callable, Dict, Any, Optional
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
from .qqofficialevent import QQOfficialEvent
|
from .qqofficialevent import QQOfficialEvent
|
||||||
import json
|
import json
|
||||||
@@ -10,38 +12,20 @@ import traceback
|
|||||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||||
|
|
||||||
|
|
||||||
def handle_validation(body: dict, bot_secret: str):
|
|
||||||
# bot正确的secert是32位的,此处仅为了适配演示demo
|
|
||||||
while len(bot_secret) < 32:
|
|
||||||
bot_secret = bot_secret * 2
|
|
||||||
bot_secret = bot_secret[:32]
|
|
||||||
# 实际使用场景中以上三行内容可清除
|
|
||||||
|
|
||||||
seed_bytes = bot_secret.encode()
|
|
||||||
|
|
||||||
signing_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed_bytes)
|
|
||||||
|
|
||||||
msg = body['d']['event_ts'] + body['d']['plain_token']
|
|
||||||
msg_bytes = msg.encode()
|
|
||||||
|
|
||||||
signature = signing_key.sign(msg_bytes)
|
|
||||||
|
|
||||||
signature_hex = signature.hex()
|
|
||||||
|
|
||||||
response = {'plain_token': body['d']['plain_token'], 'signature': signature_hex}
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
class QQOfficialClient:
|
class QQOfficialClient:
|
||||||
def __init__(self, secret: str, token: str, app_id: str, logger: None):
|
def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False):
|
||||||
|
self.unified_mode = unified_mode
|
||||||
self.app = Quart(__name__)
|
self.app = Quart(__name__)
|
||||||
|
|
||||||
|
# 只有在非统一模式下才注册独立路由
|
||||||
|
if not self.unified_mode:
|
||||||
self.app.add_url_rule(
|
self.app.add_url_rule(
|
||||||
'/callback/command',
|
'/callback/command',
|
||||||
'handle_callback',
|
'handle_callback',
|
||||||
self.handle_callback_request,
|
self.handle_callback_request,
|
||||||
methods=['GET', 'POST'],
|
methods=['GET', 'POST'],
|
||||||
)
|
)
|
||||||
|
|
||||||
self.secret = secret
|
self.secret = secret
|
||||||
self.token = token
|
self.token = token
|
||||||
self.app_id = app_id
|
self.app_id = app_id
|
||||||
@@ -50,6 +34,8 @@ class QQOfficialClient:
|
|||||||
self.access_token = ''
|
self.access_token = ''
|
||||||
self.access_token_expiry_time = None
|
self.access_token_expiry_time = None
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
self._msg_seq_counter = 0
|
||||||
|
self._token_refresh_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
async def check_access_token(self):
|
async def check_access_token(self):
|
||||||
"""检查access_token是否存在"""
|
"""检查access_token是否存在"""
|
||||||
@@ -68,32 +54,57 @@ class QQOfficialClient:
|
|||||||
headers = {
|
headers = {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
}
|
}
|
||||||
try:
|
|
||||||
response = await client.post(url, json=params, headers=headers)
|
response = await client.post(url, json=params, headers=headers)
|
||||||
if response.status_code == 200:
|
if response.status_code != 200:
|
||||||
|
raise Exception(f'Failed to get access_token: HTTP {response.status_code} {response.text}')
|
||||||
response_data = response.json()
|
response_data = response.json()
|
||||||
access_token = response_data.get('access_token')
|
access_token = response_data.get('access_token')
|
||||||
expires_in = int(response_data.get('expires_in', 7200))
|
expires_in = int(response_data.get('expires_in', 7200))
|
||||||
self.access_token_expiry_time = time.time() + expires_in - 60
|
self.access_token_expiry_time = time.time() + expires_in - 60
|
||||||
if access_token:
|
if access_token:
|
||||||
self.access_token = access_token
|
self.access_token = access_token
|
||||||
except Exception as e:
|
await self.logger.info(f'access_token obtained, expires_in={expires_in}s')
|
||||||
await self.logger.error(f'获取access_token失败: {response_data}')
|
else:
|
||||||
raise Exception(f'获取access_token失败: {e}')
|
raise Exception('Failed to get access_token: no access_token in response')
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
"""处理回调请求"""
|
"""处理回调请求(独立端口模式,使用全局 request)"""
|
||||||
|
return await self._handle_callback_internal(request)
|
||||||
|
|
||||||
|
async def handle_unified_webhook(self, req):
|
||||||
|
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req: Quart Request 对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
响应数据
|
||||||
|
"""
|
||||||
|
return await self._handle_callback_internal(req)
|
||||||
|
|
||||||
|
async def _handle_callback_internal(self, req):
|
||||||
|
"""处理回调请求的内部实现。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req: Quart Request 对象
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# 读取请求数据
|
body = await req.get_data()
|
||||||
body = await request.get_data()
|
|
||||||
|
await self.logger.info(f'Received request, body length: {len(body)}')
|
||||||
|
|
||||||
|
if not body or len(body) == 0:
|
||||||
|
await self.logger.info('Received empty body, might be health check or GET request')
|
||||||
|
return {'code': 0, 'message': 'ok'}, 200
|
||||||
|
|
||||||
payload = json.loads(body)
|
payload = json.loads(body)
|
||||||
|
|
||||||
# 验证是否为回调验证请求
|
|
||||||
if payload.get('op') == 13:
|
if payload.get('op') == 13:
|
||||||
# 生成签名
|
validation_data = payload.get('d')
|
||||||
response = handle_validation(payload, self.secret)
|
if not validation_data:
|
||||||
|
return {'error': "missing 'd' field"}, 400
|
||||||
return response
|
response = await self.verify(validation_data)
|
||||||
|
return response, 200
|
||||||
|
|
||||||
if payload.get('op') == 0:
|
if payload.get('op') == 0:
|
||||||
message_data = await self.get_message(payload)
|
message_data = await self.get_message(payload)
|
||||||
@@ -131,21 +142,24 @@ class QQOfficialClient:
|
|||||||
|
|
||||||
async def get_message(self, msg: dict) -> Dict[str, Any]:
|
async def get_message(self, msg: dict) -> Dict[str, Any]:
|
||||||
"""获取消息"""
|
"""获取消息"""
|
||||||
|
d = msg.get('d', {})
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
return {}
|
||||||
message_data = {
|
message_data = {
|
||||||
't': msg.get('t', {}),
|
't': msg.get('t', {}),
|
||||||
'user_openid': msg.get('d', {}).get('author', {}).get('user_openid', {}),
|
'user_openid': d.get('author', {}).get('user_openid', {}),
|
||||||
'timestamp': msg.get('d', {}).get('timestamp', {}),
|
'timestamp': d.get('timestamp', {}),
|
||||||
'd_author_id': msg.get('d', {}).get('author', {}).get('id', {}),
|
'd_author_id': d.get('author', {}).get('id', {}),
|
||||||
'content': msg.get('d', {}).get('content', {}),
|
'content': d.get('content', {}),
|
||||||
'd_id': msg.get('d', {}).get('id', {}),
|
'd_id': d.get('id', {}),
|
||||||
'id': msg.get('id', {}),
|
'id': msg.get('id', {}),
|
||||||
'channel_id': msg.get('d', {}).get('channel_id', {}),
|
'channel_id': d.get('channel_id', {}),
|
||||||
'username': msg.get('d', {}).get('author', {}).get('username', {}),
|
'username': d.get('author', {}).get('username', {}),
|
||||||
'guild_id': msg.get('d', {}).get('guild_id', {}),
|
'guild_id': d.get('guild_id', {}),
|
||||||
'member_openid': msg.get('d', {}).get('author', {}).get('openid', {}),
|
'member_openid': d.get('author', {}).get('openid', {}),
|
||||||
'group_openid': msg.get('d', {}).get('group_openid', {}),
|
'group_openid': d.get('group_openid', {}),
|
||||||
}
|
}
|
||||||
attachments = msg.get('d', {}).get('attachments', [])
|
attachments = d.get('attachments', [])
|
||||||
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
|
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
|
||||||
image_attachments_type = [
|
image_attachments_type = [
|
||||||
attachment['content_type'] for attachment in attachments if await self.is_image(attachment)
|
attachment['content_type'] for attachment in attachments if await self.is_image(attachment)
|
||||||
@@ -184,7 +198,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送私聊消息失败: {response_data}')
|
await self.logger.error(f'Failed to send private message: {response_data}')
|
||||||
raise ValueError(response)
|
raise ValueError(response)
|
||||||
|
|
||||||
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
|
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
|
||||||
@@ -207,7 +221,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送群聊消息失败:{response.json()}')
|
await self.logger.error(f'Failed to send group message: {response.json()}')
|
||||||
raise Exception(response.read().decode())
|
raise Exception(response.read().decode())
|
||||||
|
|
||||||
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
|
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
|
||||||
@@ -230,7 +244,7 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送频道群聊消息失败: {response.json()}')
|
await self.logger.error(f'Failed to send channel group message: {response.json()}')
|
||||||
raise Exception(response)
|
raise Exception(response)
|
||||||
|
|
||||||
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
|
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
|
||||||
@@ -253,11 +267,571 @@ class QQOfficialClient:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
await self.logger.error(f'发送频道私聊消息失败: {response.json()}')
|
await self.logger.error(f'Failed to send channel private message: {response.json()}')
|
||||||
raise Exception(response)
|
raise Exception(response)
|
||||||
|
|
||||||
|
# ---- 富媒体消息 ----
|
||||||
|
|
||||||
|
# 媒体文件类型
|
||||||
|
MEDIA_TYPE_IMAGE = 1
|
||||||
|
MEDIA_TYPE_VIDEO = 2
|
||||||
|
MEDIA_TYPE_VOICE = 3
|
||||||
|
MEDIA_TYPE_FILE = 4
|
||||||
|
|
||||||
|
async def upload_media(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_type: int,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
file_name: str = None,
|
||||||
|
) -> str:
|
||||||
|
"""上传媒体文件,返回 file_info。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_type: 'c2c' | 'group'
|
||||||
|
target_id: 用户 openid 或群 openid
|
||||||
|
file_type: 1=图片, 2=视频, 3=语音, 4=文件
|
||||||
|
file_url: 在线 URL(与 file_data 二选一)
|
||||||
|
file_data: base64 编码的文件数据或 data URL(与 file_url 二选一)
|
||||||
|
file_name: 文件名(file_type=4 时必填)
|
||||||
|
"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
if target_type == 'c2c':
|
||||||
|
url = f'{self.base_url}/v2/users/{target_id}/files'
|
||||||
|
elif target_type == 'group':
|
||||||
|
url = f'{self.base_url}/v2/groups/{target_id}/files'
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported target_type: {target_type}')
|
||||||
|
|
||||||
|
body = {
|
||||||
|
'file_type': file_type,
|
||||||
|
'srv_send_msg': False,
|
||||||
|
}
|
||||||
|
if file_url:
|
||||||
|
body['url'] = file_url
|
||||||
|
elif file_data:
|
||||||
|
# 处理 data URL 格式: data:image/png;base64,xxxxx
|
||||||
|
if file_data.startswith('data:'):
|
||||||
|
match = re.match(r'^data:[^;]+;base64,(.+)$', file_data, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
body['file_data'] = match.group(1)
|
||||||
|
else:
|
||||||
|
body['file_data'] = file_data
|
||||||
|
else:
|
||||||
|
body['file_data'] = file_data
|
||||||
|
else:
|
||||||
|
raise ValueError('file_url or file_data is required')
|
||||||
|
|
||||||
|
if file_type == self.MEDIA_TYPE_FILE and file_name:
|
||||||
|
body['file_name'] = file_name
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
file_info = data.get('file_info', '')
|
||||||
|
preview = file_info[:80] + '...' if len(file_info) > 80 else file_info
|
||||||
|
await self.logger.info(f'Upload media success, file_info={preview}')
|
||||||
|
return file_info
|
||||||
|
else:
|
||||||
|
raise Exception(f'Failed to upload media: HTTP {response.status_code} {response.text}')
|
||||||
|
|
||||||
|
async def _send_media_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_info: str,
|
||||||
|
msg_id: str = None,
|
||||||
|
content: str = None,
|
||||||
|
):
|
||||||
|
"""发送富媒体消息(msg_type=7)"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
if target_type == 'c2c':
|
||||||
|
url = f'{self.base_url}/v2/users/{target_id}/messages'
|
||||||
|
elif target_type == 'group':
|
||||||
|
url = f'{self.base_url}/v2/groups/{target_id}/messages'
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported target_type: {target_type}')
|
||||||
|
|
||||||
|
self._msg_seq_counter += 1
|
||||||
|
msg_seq = self._msg_seq_counter
|
||||||
|
body = {
|
||||||
|
'msg_type': 7,
|
||||||
|
'media': {'file_info': file_info},
|
||||||
|
'msg_seq': msg_seq,
|
||||||
|
}
|
||||||
|
if content:
|
||||||
|
body['content'] = content
|
||||||
|
if msg_id:
|
||||||
|
body['msg_id'] = msg_id
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
await self.logger.info(f'Sending rich media: {json.dumps(body, ensure_ascii=False)[:200]}')
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f'Failed to send rich media message: HTTP {response.status_code} {response.text}')
|
||||||
|
|
||||||
|
async def send_image_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
msg_id: str = None,
|
||||||
|
content: str = None,
|
||||||
|
):
|
||||||
|
"""发送图片消息"""
|
||||||
|
file_info = await self.upload_media(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
self.MEDIA_TYPE_IMAGE,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
)
|
||||||
|
await self._send_media_msg(target_type, target_id, file_info, msg_id, content)
|
||||||
|
|
||||||
|
async def send_voice_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
msg_id: str = None,
|
||||||
|
):
|
||||||
|
"""发送语音消息"""
|
||||||
|
file_info = await self.upload_media(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
self.MEDIA_TYPE_VOICE,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
)
|
||||||
|
await self._send_media_msg(target_type, target_id, file_info, msg_id)
|
||||||
|
|
||||||
|
async def send_file_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
file_url: str = None,
|
||||||
|
file_data: str = None,
|
||||||
|
file_name: str = None,
|
||||||
|
msg_id: str = None,
|
||||||
|
):
|
||||||
|
"""发送文件消息(含视频)"""
|
||||||
|
file_info = await self.upload_media(
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
self.MEDIA_TYPE_FILE,
|
||||||
|
file_url=file_url,
|
||||||
|
file_data=file_data,
|
||||||
|
file_name=file_name,
|
||||||
|
)
|
||||||
|
await self._send_media_msg(target_type, target_id, file_info, msg_id)
|
||||||
|
|
||||||
|
async def send_stream_msg(
|
||||||
|
self,
|
||||||
|
user_openid: str,
|
||||||
|
content: str,
|
||||||
|
event_id: str,
|
||||||
|
msg_id: str,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
index: int = 0,
|
||||||
|
stream_msg_id: str = None,
|
||||||
|
input_state: int = 1,
|
||||||
|
):
|
||||||
|
"""发送流式消息(C2C 私聊)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_state: 1=生成中, 10=生成结束
|
||||||
|
"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
url = f'{self.base_url}/v2/users/{user_openid}/stream_messages'
|
||||||
|
body = {
|
||||||
|
'input_mode': 'replace',
|
||||||
|
'input_state': input_state,
|
||||||
|
'content_type': 'markdown',
|
||||||
|
'content_raw': content,
|
||||||
|
'event_id': event_id,
|
||||||
|
'msg_id': msg_id,
|
||||||
|
'msg_seq': msg_seq,
|
||||||
|
'index': index,
|
||||||
|
}
|
||||||
|
if stream_msg_id:
|
||||||
|
body['stream_msg_id'] = stream_msg_id
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
response = await client.post(url, headers=headers, json=body)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}')
|
||||||
|
return response.json()
|
||||||
|
|
||||||
async def is_token_expired(self):
|
async def is_token_expired(self):
|
||||||
"""检查token是否过期"""
|
"""检查token是否过期"""
|
||||||
if self.access_token_expiry_time is None:
|
if self.access_token_expiry_time is None:
|
||||||
return True
|
return True
|
||||||
return time.time() > self.access_token_expiry_time
|
return time.time() > self.access_token_expiry_time
|
||||||
|
|
||||||
|
async def repeat_seed(self, bot_secret: str, target_size: int = 32) -> bytes:
|
||||||
|
seed = bot_secret
|
||||||
|
while len(seed) < target_size:
|
||||||
|
seed *= 2
|
||||||
|
return seed[:target_size].encode('utf-8')
|
||||||
|
|
||||||
|
async def verify(self, validation_payload: dict):
|
||||||
|
seed = await self.repeat_seed(self.secret)
|
||||||
|
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed)
|
||||||
|
|
||||||
|
event_ts = validation_payload.get('event_ts', '')
|
||||||
|
plain_token = validation_payload.get('plain_token', '')
|
||||||
|
msg = event_ts + plain_token
|
||||||
|
|
||||||
|
# sign
|
||||||
|
signature = private_key.sign(msg.encode()).hex()
|
||||||
|
|
||||||
|
response = {
|
||||||
|
'plain_token': plain_token,
|
||||||
|
'signature': signature,
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
|
||||||
|
# ---- WebSocket Gateway ----
|
||||||
|
# Reference: https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html
|
||||||
|
|
||||||
|
INTENT_GUILDS = 1 << 0
|
||||||
|
INTENT_GUILD_MEMBERS = 1 << 1
|
||||||
|
INTENT_PUBLIC_GUILD_MESSAGES = 1 << 30
|
||||||
|
INTENT_DIRECT_MESSAGE = 1 << 12
|
||||||
|
INTENT_GROUP_AND_C2C = 1 << 25
|
||||||
|
INTENT_INTERACTION = 1 << 26
|
||||||
|
|
||||||
|
FULL_INTENTS = (
|
||||||
|
INTENT_GUILDS
|
||||||
|
| INTENT_GUILD_MEMBERS
|
||||||
|
| INTENT_PUBLIC_GUILD_MESSAGES
|
||||||
|
| INTENT_DIRECT_MESSAGE
|
||||||
|
| INTENT_GROUP_AND_C2C
|
||||||
|
| INTENT_INTERACTION
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_gateway_url(self) -> str:
|
||||||
|
"""获取 WebSocket 网关地址"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
url = f'{self.base_url}/gateway'
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
}
|
||||||
|
response = await client.get(url, headers=headers)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
ws_url = data.get('url', '')
|
||||||
|
if not ws_url:
|
||||||
|
raise Exception('Gateway URL is empty')
|
||||||
|
return ws_url
|
||||||
|
else:
|
||||||
|
raise Exception(f'Failed to get Gateway URL: HTTP {response.status_code} {response.text}')
|
||||||
|
|
||||||
|
async def _background_token_refresh(self):
|
||||||
|
"""在 token 到期前主动刷新"""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if self.access_token_expiry_time:
|
||||||
|
remain = self.access_token_expiry_time - time.time()
|
||||||
|
if remain > 120:
|
||||||
|
await asyncio.sleep(remain - 60)
|
||||||
|
continue
|
||||||
|
self.access_token = ''
|
||||||
|
self.access_token_expiry_time = None
|
||||||
|
if await self.check_access_token():
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
else:
|
||||||
|
await self.get_access_token()
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def connect_gateway(
|
||||||
|
self,
|
||||||
|
on_event: Callable[[str, dict], Any],
|
||||||
|
on_ready: Optional[Callable[[], Any]] = None,
|
||||||
|
on_error: Optional[Callable[[Exception], Any]] = None,
|
||||||
|
):
|
||||||
|
"""WebSocket 网关连接,含重连逻辑。持续重连直到达到最大次数或被取消。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
on_event: 收到 op=0 Dispatch 事件时的回调,参数为 (event_type, event_data)
|
||||||
|
on_ready: 连接就绪 (收到 READY) 时的回调
|
||||||
|
on_error: 发生错误时的回调
|
||||||
|
"""
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
reconnect_attempts = 0
|
||||||
|
max_reconnect_attempts = 100
|
||||||
|
backoff_delays = [1, 2, 5, 10, 30, 60]
|
||||||
|
rate_limit_delay = 60
|
||||||
|
|
||||||
|
# Cancel previous token refresh task if any
|
||||||
|
if self._token_refresh_task and not self._token_refresh_task.done():
|
||||||
|
self._token_refresh_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._token_refresh_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._token_refresh_task = None
|
||||||
|
|
||||||
|
while reconnect_attempts <= max_reconnect_attempts:
|
||||||
|
heartbeat_interval = 45000
|
||||||
|
should_refresh_token = False
|
||||||
|
ws = None
|
||||||
|
heartbeat_task = None
|
||||||
|
|
||||||
|
# Refresh token if needed
|
||||||
|
if should_refresh_token:
|
||||||
|
self.access_token = ''
|
||||||
|
self.access_token_expiry_time = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
ws_url = await self.get_gateway_url()
|
||||||
|
await self.logger.info(f'Gateway URL obtained: {ws_url[:60]}...')
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
await self.logger.error(f'Failed to get gateway URL: {e}')
|
||||||
|
reconnect_attempts += 1
|
||||||
|
if '100017' in error_msg or '频率' in error_msg or 'Too many' in error_msg:
|
||||||
|
delay = rate_limit_delay
|
||||||
|
else:
|
||||||
|
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
|
||||||
|
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.logger.info('Connecting to WebSocket gateway...')
|
||||||
|
ws = await websockets.connect(ws_url)
|
||||||
|
await self.logger.info('WebSocket connected')
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.error(f'WebSocket connection failed: {e}')
|
||||||
|
reconnect_attempts += 1
|
||||||
|
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
|
||||||
|
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for raw_msg in ws:
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_msg)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await self.logger.error(f'Failed to parse message: {raw_msg}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
op = payload.get('op')
|
||||||
|
d = payload.get('d', {})
|
||||||
|
s = payload.get('s')
|
||||||
|
t = payload.get('t')
|
||||||
|
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
d = {}
|
||||||
|
|
||||||
|
if op == 10: # Hello
|
||||||
|
heartbeat_interval = d.get('heartbeat_interval', 45000)
|
||||||
|
await self.logger.info(f'Received Hello, heartbeat_interval={heartbeat_interval}ms')
|
||||||
|
|
||||||
|
# Send Identify or Resume
|
||||||
|
if session_id and last_seq > 0:
|
||||||
|
resume_payload = {
|
||||||
|
'op': 6,
|
||||||
|
'd': {
|
||||||
|
'token': f'QQBot {self.access_token}',
|
||||||
|
'session_id': session_id,
|
||||||
|
'seq': last_seq,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(resume_payload))
|
||||||
|
await self.logger.info(f'Sent Resume, session_id={session_id}, seq={last_seq}')
|
||||||
|
else:
|
||||||
|
identify_payload = {
|
||||||
|
'op': 2,
|
||||||
|
'd': {
|
||||||
|
'token': f'QQBot {self.access_token}',
|
||||||
|
'intents': self.FULL_INTENTS,
|
||||||
|
'shard': [0, 1],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(identify_payload))
|
||||||
|
await self.logger.info(f'Sent Identify, intents={self.FULL_INTENTS}')
|
||||||
|
|
||||||
|
# Start heartbeat
|
||||||
|
async def _heartbeat_loop(conn, interval_ms):
|
||||||
|
interval_sec = interval_ms / 1000.0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval_sec)
|
||||||
|
try:
|
||||||
|
hb_payload = {'op': 1, 'd': last_seq}
|
||||||
|
await conn.send(json.dumps(hb_payload))
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
heartbeat_task = asyncio.create_task(_heartbeat_loop(ws, heartbeat_interval))
|
||||||
|
|
||||||
|
elif op == 0: # Dispatch
|
||||||
|
if s is not None:
|
||||||
|
last_seq = s
|
||||||
|
|
||||||
|
if t == 'READY':
|
||||||
|
session_id = d.get('session_id', '')
|
||||||
|
reconnect_attempts = 0
|
||||||
|
await self.logger.info(f'READY, session_id={session_id}')
|
||||||
|
if on_ready:
|
||||||
|
try:
|
||||||
|
result = on_ready()
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Track token refresh task to avoid leaks
|
||||||
|
if self._token_refresh_task and not self._token_refresh_task.done():
|
||||||
|
self._token_refresh_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._token_refresh_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._token_refresh_task = asyncio.create_task(self._background_token_refresh())
|
||||||
|
|
||||||
|
elif t == 'RESUMED':
|
||||||
|
reconnect_attempts = 0
|
||||||
|
await self.logger.info('RESUMED')
|
||||||
|
|
||||||
|
else:
|
||||||
|
await self.logger.debug(f'Received event: {t}, seq={s}')
|
||||||
|
if on_event:
|
||||||
|
try:
|
||||||
|
result = on_event(t, d)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling event {t}: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
elif op == 11: # Heartbeat ACK
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif op == 7: # Reconnect
|
||||||
|
await self.logger.info('Received Reconnect directive')
|
||||||
|
break
|
||||||
|
|
||||||
|
elif op == 9: # Invalid Session
|
||||||
|
can_resume = d.get('can_resume', False)
|
||||||
|
await self.logger.warning(f'Invalid Session, can_resume={can_resume}')
|
||||||
|
if not can_resume:
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
should_refresh_token = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Connection closed normally (end of async for)
|
||||||
|
try:
|
||||||
|
close_code = ws.close_code
|
||||||
|
close_reason = ws.close_reason or ''
|
||||||
|
except Exception:
|
||||||
|
close_code = None
|
||||||
|
close_reason = ''
|
||||||
|
await self.logger.info(f'Connection closed, code={close_code}, reason={close_reason}')
|
||||||
|
|
||||||
|
if close_code == 4004:
|
||||||
|
should_refresh_token = True
|
||||||
|
elif close_code in (4006, 4007, 4009):
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
should_refresh_token = True
|
||||||
|
elif close_code == 4008:
|
||||||
|
reconnect_attempts += 1
|
||||||
|
delay = rate_limit_delay
|
||||||
|
await self.logger.info(
|
||||||
|
f'Rate limited, waiting {delay}s before reconnect (attempt {reconnect_attempts})'
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
elif close_code in (4914, 4915):
|
||||||
|
err = Exception(f'Bot disconnected/banned (close_code={close_code})')
|
||||||
|
if on_error:
|
||||||
|
await self._safe_callback(on_error, err)
|
||||||
|
return
|
||||||
|
elif close_code in (4900, 4901, 4902, 4903, 4904, 4905, 4906, 4907, 4908, 4909, 4910, 4911, 4912, 4913):
|
||||||
|
session_id = ''
|
||||||
|
last_seq = 0
|
||||||
|
|
||||||
|
if close_code == 1000:
|
||||||
|
return
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Unexpected error in WebSocket loop: {traceback.format_exc()}')
|
||||||
|
finally:
|
||||||
|
if heartbeat_task:
|
||||||
|
heartbeat_task.cancel()
|
||||||
|
try:
|
||||||
|
await heartbeat_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
if ws:
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If we reach here, we need to reconnect
|
||||||
|
reconnect_attempts += 1
|
||||||
|
if reconnect_attempts > max_reconnect_attempts:
|
||||||
|
await self.logger.error(f'Max reconnect attempts ({max_reconnect_attempts}) reached, stopping')
|
||||||
|
if on_error:
|
||||||
|
await self._safe_callback(on_error, Exception('Max reconnect attempts reached'))
|
||||||
|
return
|
||||||
|
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
|
||||||
|
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
async def _safe_callback(self, callback, *args):
|
||||||
|
"""Safely invoke a callback, handling both sync and async functions."""
|
||||||
|
try:
|
||||||
|
result = callback(*args)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def connect_gateway_loop(
|
||||||
|
self,
|
||||||
|
on_event: Callable[[str, dict], Any],
|
||||||
|
on_ready: Optional[Callable[[], Any]] = None,
|
||||||
|
on_error: Optional[Callable[[Exception], Any]] = None,
|
||||||
|
):
|
||||||
|
"""持续重连的网关循环。"""
|
||||||
|
await self.connect_gateway(on_event, on_ready, on_error)
|
||||||
|
|||||||
@@ -8,14 +8,19 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|||||||
|
|
||||||
|
|
||||||
class SlackClient:
|
class SlackClient:
|
||||||
def __init__(self, bot_token: str, signing_secret: str, logger: None):
|
def __init__(self, bot_token: str, signing_secret: str, logger: None, unified_mode: bool = False):
|
||||||
self.bot_token = bot_token
|
self.bot_token = bot_token
|
||||||
self.signing_secret = signing_secret
|
self.signing_secret = signing_secret
|
||||||
|
self.unified_mode = unified_mode
|
||||||
self.app = Quart(__name__)
|
self.app = Quart(__name__)
|
||||||
self.client = AsyncWebClient(self.bot_token)
|
self.client = AsyncWebClient(self.bot_token)
|
||||||
|
|
||||||
|
# 只有在非统一模式下才注册独立路由
|
||||||
|
if not self.unified_mode:
|
||||||
self.app.add_url_rule(
|
self.app.add_url_rule(
|
||||||
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']
|
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']
|
||||||
)
|
)
|
||||||
|
|
||||||
self._message_handlers = {
|
self._message_handlers = {
|
||||||
'example': [],
|
'example': [],
|
||||||
}
|
}
|
||||||
@@ -23,8 +28,28 @@ class SlackClient:
|
|||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
|
"""处理回调请求(独立端口模式,使用全局 request)"""
|
||||||
|
return await self._handle_callback_internal(request)
|
||||||
|
|
||||||
|
async def handle_unified_webhook(self, req):
|
||||||
|
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req: Quart Request 对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
响应数据
|
||||||
|
"""
|
||||||
|
return await self._handle_callback_internal(req)
|
||||||
|
|
||||||
|
async def _handle_callback_internal(self, req):
|
||||||
|
"""处理回调请求的内部实现。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req: Quart Request 对象
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
body = await request.get_data()
|
body = await req.get_data()
|
||||||
data = json.loads(body)
|
data = json.loads(body)
|
||||||
if 'type' in data:
|
if 'type' in data:
|
||||||
if data['type'] == 'url_verification':
|
if data['type'] == 'url_verification':
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import requests
|
import requests
|
||||||
import aiohttp
|
from langbot.pkg.utils import httpclient
|
||||||
|
|
||||||
|
|
||||||
def post_json(base_url, token, data=None):
|
def post_json(base_url, token, data=None):
|
||||||
@@ -63,7 +63,7 @@ async def async_request(
|
|||||||
"""
|
"""
|
||||||
headers = {'Content-Type': 'application/json'}
|
headers = {'Content-Type': 'application/json'}
|
||||||
url = f'{base_url}?key={token_key}'
|
url = f'{base_url}?key={token_key}'
|
||||||
async with aiohttp.ClientSession() as session:
|
session = httpclient.get_session()
|
||||||
async with session.request(
|
async with session.request(
|
||||||
method=method, url=url, params=params, headers=headers, data=data, json=json
|
method=method, url=url, params=params, headers=headers, data=data, json=json
|
||||||
) as response:
|
) as response:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
@@ -6,7 +8,8 @@ import traceback
|
|||||||
import uuid
|
import uuid
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable, Optional
|
import re
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -15,6 +18,8 @@ from quart import Quart, request, Response, jsonify
|
|||||||
|
|
||||||
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||||
from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
|
from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
from langbot.pkg.platform.logger import EventLogger
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
|
|
||||||
@@ -63,16 +68,25 @@ class StreamSession:
|
|||||||
# 缓存最近一次片段,处理重试或超时兜底
|
# 缓存最近一次片段,处理重试或超时兜底
|
||||||
last_chunk: Optional[StreamChunk] = None
|
last_chunk: Optional[StreamChunk] = None
|
||||||
|
|
||||||
|
# 反馈 ID,用于接收用户点赞/点踩反馈
|
||||||
|
feedback_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class StreamSessionManager:
|
class StreamSessionManager:
|
||||||
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
||||||
|
|
||||||
|
# Sessions with registered feedback_ids use a longer TTL to survive the
|
||||||
|
# full like → cancel → dislike feedback flow. Must align with the adapter's
|
||||||
|
# _stream_to_monitoring_msg TTL (wecombot.py).
|
||||||
|
_FEEDBACK_SESSION_TTL = 600 # 10 minutes
|
||||||
|
|
||||||
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
|
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
|
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
|
||||||
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
|
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
|
||||||
self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话
|
self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话
|
||||||
|
self._feedback_index: dict[str, str] = {} # feedback_id -> stream_id 映射
|
||||||
|
|
||||||
def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:
|
def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:
|
||||||
if not msg_id:
|
if not msg_id:
|
||||||
@@ -82,6 +96,32 @@ class StreamSessionManager:
|
|||||||
def get_session(self, stream_id: str) -> Optional[StreamSession]:
|
def get_session(self, stream_id: str) -> Optional[StreamSession]:
|
||||||
return self._sessions.get(stream_id)
|
return self._sessions.get(stream_id)
|
||||||
|
|
||||||
|
def get_session_by_feedback_id(self, feedback_id: str) -> Optional[StreamSession]:
|
||||||
|
"""根据 feedback_id 查找会话。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feedback_id: 企业微信反馈事件中的反馈 ID。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[StreamSession]: 找到的会话实例,未找到返回 None。
|
||||||
|
"""
|
||||||
|
if not feedback_id:
|
||||||
|
return None
|
||||||
|
stream_id = self._feedback_index.get(feedback_id)
|
||||||
|
if stream_id:
|
||||||
|
return self._sessions.get(stream_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def register_feedback_id(self, stream_id: str, feedback_id: str) -> None:
|
||||||
|
"""注册 feedback_id 与 stream_id 的映射。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stream_id: 企业微信流式会话 ID。
|
||||||
|
feedback_id: 反馈 ID。
|
||||||
|
"""
|
||||||
|
if feedback_id and stream_id:
|
||||||
|
self._feedback_index[feedback_id] = stream_id
|
||||||
|
|
||||||
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
|
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
|
||||||
"""根据企业微信回调创建或获取会话。
|
"""根据企业微信回调创建或获取会话。
|
||||||
|
|
||||||
@@ -183,11 +223,17 @@ class StreamSessionManager:
|
|||||||
session.last_access = time.time()
|
session.last_access = time.time()
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
"""定期清理过期会话,防止队列与映射无上限累积。"""
|
"""定期清理过期会话,防止队列与映射无上限累积。
|
||||||
|
|
||||||
|
已注册 feedback_id 的会话使用更长的 TTL,确保用户在点赞/取消/点踩流程中
|
||||||
|
不会因为 session 被提前清除而丢失上下文信息。
|
||||||
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
expired: list[str] = []
|
expired: list[str] = []
|
||||||
for stream_id, session in self._sessions.items():
|
for stream_id, session in self._sessions.items():
|
||||||
if now - session.last_access > self.ttl:
|
# Sessions with registered feedback_ids use a longer TTL
|
||||||
|
effective_ttl = self._FEEDBACK_SESSION_TTL if session.feedback_id else self.ttl
|
||||||
|
if now - session.last_access > effective_ttl:
|
||||||
expired.append(stream_id)
|
expired.append(stream_id)
|
||||||
|
|
||||||
for stream_id in expired:
|
for stream_id in expired:
|
||||||
@@ -197,10 +243,492 @@ class StreamSessionManager:
|
|||||||
msg_id = session.msg_id
|
msg_id = session.msg_id
|
||||||
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
||||||
self._msg_index.pop(msg_id, None)
|
self._msg_index.pop(msg_id, None)
|
||||||
|
# Clean up feedback index for expired sessions
|
||||||
|
if session.feedback_id:
|
||||||
|
self._feedback_index.pop(session.feedback_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
|
||||||
|
"""Decrypt AES-256-CBC encrypted file data.
|
||||||
|
|
||||||
|
Aligned with the official WeCom AI Bot Python SDK (crypto_utils.py).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encrypted_data: The raw encrypted bytes.
|
||||||
|
aes_key_str: Base64-encoded AES key (may lack padding).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decrypted bytes with PKCS#7 padding removed.
|
||||||
|
"""
|
||||||
|
if not encrypted_data:
|
||||||
|
raise ValueError('encrypted_data is empty')
|
||||||
|
if not aes_key_str:
|
||||||
|
raise ValueError('aes_key is empty')
|
||||||
|
|
||||||
|
# Python's base64.b64decode requires proper padding (length % 4 == 0).
|
||||||
|
# Node.js Buffer.from tolerates missing '=', so we must pad manually.
|
||||||
|
remainder = len(aes_key_str) % 4
|
||||||
|
if remainder != 0:
|
||||||
|
aes_key_str = aes_key_str + '=' * (4 - remainder)
|
||||||
|
key = base64.b64decode(aes_key_str)
|
||||||
|
|
||||||
|
iv = key[:16]
|
||||||
|
|
||||||
|
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||||
|
|
||||||
|
# Ensure encrypted data is aligned to AES block size (16 bytes).
|
||||||
|
# Node.js setAutoPadding(false) silently handles unaligned data,
|
||||||
|
# but PyCryptodome will raise an error.
|
||||||
|
block_size = 16
|
||||||
|
data_remainder = len(encrypted_data) % block_size
|
||||||
|
if data_remainder != 0:
|
||||||
|
encrypted_data = encrypted_data + b'\x00' * (block_size - data_remainder)
|
||||||
|
|
||||||
|
decrypted = cipher.decrypt(encrypted_data)
|
||||||
|
|
||||||
|
# Remove PKCS#7 padding with validation
|
||||||
|
if len(decrypted) == 0:
|
||||||
|
raise ValueError('Decrypted data is empty')
|
||||||
|
|
||||||
|
pad_len = decrypted[-1]
|
||||||
|
if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted):
|
||||||
|
raise ValueError(f'Invalid PKCS#7 padding value: {pad_len}')
|
||||||
|
|
||||||
|
# Verify all padding bytes are consistent
|
||||||
|
for i in range(len(decrypted) - pad_len, len(decrypted)):
|
||||||
|
if decrypted[i] != pad_len:
|
||||||
|
raise ValueError('Invalid PKCS#7 padding: padding bytes mismatch')
|
||||||
|
|
||||||
|
return decrypted[: len(decrypted) - pad_len]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_filename(content_disposition: str) -> Optional[str]:
|
||||||
|
"""Extract filename from a Content-Disposition header value."""
|
||||||
|
if not content_disposition:
|
||||||
|
return None
|
||||||
|
# RFC 5987: filename*=UTF-8''xxx
|
||||||
|
utf8_match = re.search(r"filename\*=UTF-8''([^;\s]+)", content_disposition, re.IGNORECASE)
|
||||||
|
if utf8_match:
|
||||||
|
return unquote(utf8_match.group(1))
|
||||||
|
# Standard: filename="xxx" or filename=xxx
|
||||||
|
match = re.search(r'filename="?([^";\s]+)"?', content_disposition, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return unquote(match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bytes_to_data_uri(data: bytes) -> str:
|
||||||
|
"""Convert raw bytes to a data URI with auto-detected MIME type."""
|
||||||
|
if data.startswith(b'\xff\xd8'):
|
||||||
|
mime_type = 'image/jpeg'
|
||||||
|
elif data.startswith(b'\x89PNG'):
|
||||||
|
mime_type = 'image/png'
|
||||||
|
elif data.startswith((b'GIF87a', b'GIF89a')):
|
||||||
|
mime_type = 'image/gif'
|
||||||
|
elif data.startswith(b'BM'):
|
||||||
|
mime_type = 'image/bmp'
|
||||||
|
elif data.startswith(b'II*\x00') or data.startswith(b'MM\x00*'):
|
||||||
|
mime_type = 'image/tiff'
|
||||||
|
elif data[:4] == b'%PDF':
|
||||||
|
mime_type = 'application/pdf'
|
||||||
|
elif data[:4] == b'PK\x03\x04':
|
||||||
|
mime_type = 'application/zip'
|
||||||
|
else:
|
||||||
|
mime_type = 'application/octet-stream'
|
||||||
|
|
||||||
|
base64_str = base64.b64encode(data).decode('utf-8')
|
||||||
|
return f'data:{mime_type};base64,{base64_str}'
|
||||||
|
|
||||||
|
|
||||||
|
async def download_encrypted_file(
|
||||||
|
download_url: str, aes_key: str, logger: EventLogger
|
||||||
|
) -> Tuple[Optional[bytes], Optional[str]]:
|
||||||
|
"""Download an AES-encrypted file from WeChat Work and decrypt it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
download_url: The encrypted file download URL.
|
||||||
|
aes_key: The AES key for decryption (base64-encoded, per-message aeskey
|
||||||
|
or platform EncodingAESKey).
|
||||||
|
logger: Logger instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of (decrypted_bytes, filename) or (None, None) on failure.
|
||||||
|
"""
|
||||||
|
if not download_url:
|
||||||
|
return None, None
|
||||||
|
if not aes_key:
|
||||||
|
await logger.error('download_encrypted_file: aes_key is empty, cannot decrypt')
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
filename: Optional[str] = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(download_url)
|
||||||
|
if response.status_code != 200:
|
||||||
|
await logger.error(f'Failed to download file (HTTP {response.status_code}): {response.text[:200]}')
|
||||||
|
return None, None
|
||||||
|
encrypted_bytes = response.content
|
||||||
|
filename = _extract_filename(response.headers.get('content-disposition', ''))
|
||||||
|
except Exception:
|
||||||
|
await logger.error(f'Failed to download file: {traceback.format_exc()}')
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
decrypted = _decrypt_file(encrypted_bytes, aes_key)
|
||||||
|
return decrypted, filename
|
||||||
|
except Exception:
|
||||||
|
await logger.error(f'Failed to decrypt file: {traceback.format_exc()}')
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_wecom_bot_message(
|
||||||
|
msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Parse a decrypted WeChat Work AI Bot message JSON into a unified message dict.
|
||||||
|
|
||||||
|
This is the shared message parsing logic used by both webhook and WebSocket modes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg_json: The decrypted message JSON from WeChat Work.
|
||||||
|
encoding_aes_key: AES key for file decryption.
|
||||||
|
logger: Logger instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict suitable for constructing a WecomBotEvent.
|
||||||
|
"""
|
||||||
|
message_data: dict[str, Any] = {}
|
||||||
|
|
||||||
|
msg_type = msg_json.get('msgtype', '')
|
||||||
|
if msg_type:
|
||||||
|
message_data['msgtype'] = msg_type
|
||||||
|
|
||||||
|
if msg_json.get('chattype', '') == 'single':
|
||||||
|
message_data['type'] = 'single'
|
||||||
|
elif msg_json.get('chattype', '') == 'group':
|
||||||
|
message_data['type'] = 'group'
|
||||||
|
|
||||||
|
max_inline_file_size = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
async def _safe_download(url: str, per_msg_aeskey: str = '') -> Tuple[Optional[bytes], Optional[str]]:
|
||||||
|
"""Download and decrypt a file, preferring per-message aeskey over platform key."""
|
||||||
|
if not url:
|
||||||
|
return None, None
|
||||||
|
key = per_msg_aeskey or encoding_aes_key
|
||||||
|
if not key:
|
||||||
|
await logger.warning('No AES key available for file decryption, skipping download')
|
||||||
|
return None, None
|
||||||
|
return await download_encrypted_file(url, key, logger)
|
||||||
|
|
||||||
|
async def _safe_download_as_data_uri(url: str, per_msg_aeskey: str = '') -> Optional[str]:
|
||||||
|
"""Download, decrypt, and convert to data URI for backward compatibility."""
|
||||||
|
data, _filename = await _safe_download(url, per_msg_aeskey)
|
||||||
|
if data:
|
||||||
|
return _bytes_to_data_uri(data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if msg_type == 'text':
|
||||||
|
message_data['content'] = msg_json.get('text', {}).get('content')
|
||||||
|
elif msg_type == 'markdown':
|
||||||
|
message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get(
|
||||||
|
'content', ''
|
||||||
|
)
|
||||||
|
elif msg_type == 'image':
|
||||||
|
image_info = msg_json.get('image', {})
|
||||||
|
picurl = image_info.get('url', '')
|
||||||
|
per_msg_aeskey = image_info.get('aeskey', '')
|
||||||
|
base64_data = await _safe_download_as_data_uri(picurl, per_msg_aeskey)
|
||||||
|
if base64_data:
|
||||||
|
message_data['picurl'] = base64_data
|
||||||
|
message_data['images'] = [base64_data]
|
||||||
|
elif msg_type == 'voice':
|
||||||
|
voice_info = msg_json.get('voice', {}) or {}
|
||||||
|
download_url = voice_info.get('url')
|
||||||
|
per_msg_aeskey = voice_info.get('aeskey', '')
|
||||||
|
message_data['voice'] = {
|
||||||
|
'url': download_url,
|
||||||
|
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||||
|
'filesize': voice_info.get('filesize') or voice_info.get('size'),
|
||||||
|
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
|
||||||
|
}
|
||||||
|
if voice_info.get('content'):
|
||||||
|
message_data['content'] = voice_info.get('content')
|
||||||
|
# if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
# voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||||
|
# if voice_base64:
|
||||||
|
# message_data['voice']['base64'] = voice_base64
|
||||||
|
elif msg_type == 'video':
|
||||||
|
video_info = msg_json.get('video', {}) or {}
|
||||||
|
download_url = video_info.get('url')
|
||||||
|
per_msg_aeskey = video_info.get('aeskey', '')
|
||||||
|
video_data = {
|
||||||
|
'url': download_url,
|
||||||
|
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||||
|
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
|
||||||
|
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||||
|
'filename': video_info.get('filename') or video_info.get('name'),
|
||||||
|
}
|
||||||
|
# if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
# video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||||
|
# if video_base64:
|
||||||
|
# video_data['base64'] = video_base64
|
||||||
|
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
|
||||||
|
video_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
|
||||||
|
message_data['video'] = video_data
|
||||||
|
elif msg_type == 'file':
|
||||||
|
file_info = msg_json.get('file', {}) or {}
|
||||||
|
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||||
|
per_msg_aeskey = file_info.get('aeskey', '')
|
||||||
|
file_data = {
|
||||||
|
'filename': file_info.get('filename') or file_info.get('name'),
|
||||||
|
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||||
|
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
|
||||||
|
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
|
||||||
|
'download_url': download_url,
|
||||||
|
'extra': file_info,
|
||||||
|
}
|
||||||
|
# if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
# file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
|
||||||
|
# if file_bytes:
|
||||||
|
# file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
||||||
|
# if dl_filename and not file_data.get('filename'):
|
||||||
|
# file_data['filename'] = dl_filename
|
||||||
|
|
||||||
|
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
|
||||||
|
file_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
|
||||||
|
message_data['file'] = file_data
|
||||||
|
elif msg_type == 'link':
|
||||||
|
message_data['link'] = msg_json.get('link', {})
|
||||||
|
if not message_data.get('content'):
|
||||||
|
title = message_data['link'].get('title', '')
|
||||||
|
desc = message_data['link'].get('description') or message_data['link'].get('digest', '')
|
||||||
|
message_data['content'] = '\n'.join(filter(None, [title, desc]))
|
||||||
|
elif msg_type == 'mixed':
|
||||||
|
items = msg_json.get('mixed', {}).get('msg_item', [])
|
||||||
|
texts = []
|
||||||
|
images = []
|
||||||
|
files = []
|
||||||
|
voices = []
|
||||||
|
videos = []
|
||||||
|
links = []
|
||||||
|
for item in items:
|
||||||
|
item_type = item.get('msgtype')
|
||||||
|
if item_type == 'text':
|
||||||
|
texts.append(item.get('text', {}).get('content', ''))
|
||||||
|
elif item_type == 'image':
|
||||||
|
img_info = item.get('image', {})
|
||||||
|
img_url = img_info.get('url')
|
||||||
|
img_aeskey = img_info.get('aeskey', '')
|
||||||
|
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
|
||||||
|
if base64_data:
|
||||||
|
images.append(base64_data)
|
||||||
|
elif item_type == 'file':
|
||||||
|
file_info = item.get('file', {}) or {}
|
||||||
|
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||||
|
item_aeskey = file_info.get('aeskey', '')
|
||||||
|
file_data = {
|
||||||
|
'filename': file_info.get('filename') or file_info.get('name'),
|
||||||
|
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||||
|
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
|
||||||
|
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
|
||||||
|
'download_url': download_url,
|
||||||
|
'extra': file_info,
|
||||||
|
}
|
||||||
|
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
file_bytes, dl_filename = await _safe_download(download_url, item_aeskey)
|
||||||
|
if file_bytes:
|
||||||
|
file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
||||||
|
if dl_filename and not file_data.get('filename'):
|
||||||
|
file_data['filename'] = dl_filename
|
||||||
|
files.append(file_data)
|
||||||
|
elif item_type == 'voice':
|
||||||
|
voice_info = item.get('voice', {}) or {}
|
||||||
|
download_url = voice_info.get('url')
|
||||||
|
item_aeskey = voice_info.get('aeskey', '')
|
||||||
|
voice_data = {
|
||||||
|
'url': download_url,
|
||||||
|
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||||
|
'filesize': voice_info.get('filesize') or voice_info.get('size'),
|
||||||
|
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
|
||||||
|
}
|
||||||
|
if voice_info.get('content'):
|
||||||
|
texts.append(voice_info.get('content'))
|
||||||
|
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
voice_base64 = await _safe_download_as_data_uri(download_url, item_aeskey)
|
||||||
|
if voice_base64:
|
||||||
|
voice_data['base64'] = voice_base64
|
||||||
|
voices.append(voice_data)
|
||||||
|
elif item_type == 'video':
|
||||||
|
video_info = item.get('video', {}) or {}
|
||||||
|
download_url = video_info.get('url')
|
||||||
|
item_aeskey = video_info.get('aeskey', '')
|
||||||
|
video_data = {
|
||||||
|
'url': download_url,
|
||||||
|
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||||
|
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
|
||||||
|
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||||
|
'filename': video_info.get('filename') or video_info.get('name'),
|
||||||
|
}
|
||||||
|
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
video_base64 = await _safe_download_as_data_uri(download_url, item_aeskey)
|
||||||
|
if video_base64:
|
||||||
|
video_data['base64'] = video_base64
|
||||||
|
videos.append(video_data)
|
||||||
|
elif item_type == 'link':
|
||||||
|
links.append(item.get('link', {}))
|
||||||
|
|
||||||
|
if texts:
|
||||||
|
message_data['content'] = ' '.join(texts)
|
||||||
|
if images:
|
||||||
|
message_data['images'] = images
|
||||||
|
message_data['picurl'] = images[0]
|
||||||
|
if files:
|
||||||
|
message_data['files'] = files
|
||||||
|
message_data['file'] = files[0]
|
||||||
|
if voices:
|
||||||
|
message_data['voices'] = voices
|
||||||
|
message_data['voice'] = voices[0]
|
||||||
|
if videos:
|
||||||
|
message_data['videos'] = videos
|
||||||
|
message_data['video'] = videos[0]
|
||||||
|
if links:
|
||||||
|
message_data['link'] = links[0]
|
||||||
|
if items:
|
||||||
|
message_data['attachments'] = items
|
||||||
|
else:
|
||||||
|
message_data['raw_msg'] = msg_json
|
||||||
|
|
||||||
|
from_info = msg_json.get('from', {})
|
||||||
|
message_data['userid'] = from_info.get('userid', '')
|
||||||
|
message_data['username'] = from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')
|
||||||
|
|
||||||
|
if msg_json.get('chattype', '') == 'group':
|
||||||
|
message_data['chatid'] = msg_json.get('chatid', '')
|
||||||
|
message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '')
|
||||||
|
|
||||||
|
message_data['msgid'] = msg_json.get('msgid', '')
|
||||||
|
|
||||||
|
if msg_json.get('aibotid'):
|
||||||
|
message_data['aibotid'] = msg_json.get('aibotid', '')
|
||||||
|
|
||||||
|
# Handle quote (referenced message) - important for group chat file references
|
||||||
|
quote_info = msg_json.get('quote')
|
||||||
|
if quote_info:
|
||||||
|
quote_data: dict[str, Any] = {}
|
||||||
|
quote_type = quote_info.get('msgtype', '')
|
||||||
|
quote_data['msgtype'] = quote_type
|
||||||
|
|
||||||
|
if quote_type == 'text':
|
||||||
|
quote_data['content'] = quote_info.get('text', {}).get('content', '')
|
||||||
|
elif quote_type == 'image':
|
||||||
|
img_info = quote_info.get('image', {})
|
||||||
|
img_url = img_info.get('url', '')
|
||||||
|
img_aeskey = img_info.get('aeskey', '')
|
||||||
|
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
|
||||||
|
if base64_data:
|
||||||
|
quote_data['picurl'] = base64_data
|
||||||
|
quote_data['images'] = [base64_data]
|
||||||
|
elif quote_type == 'file':
|
||||||
|
file_info = quote_info.get('file', {}) or {}
|
||||||
|
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||||
|
item_aeskey = file_info.get('aeskey', '')
|
||||||
|
file_data = {
|
||||||
|
'filename': file_info.get('filename') or file_info.get('name'),
|
||||||
|
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||||
|
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
|
||||||
|
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
|
||||||
|
'download_url': download_url,
|
||||||
|
'extra': file_info,
|
||||||
|
}
|
||||||
|
# Same as private chat: append aeskey to download_url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
quote_data['file'] = file_data
|
||||||
|
elif quote_type == 'voice':
|
||||||
|
voice_info = quote_info.get('voice', {}) or {}
|
||||||
|
download_url = voice_info.get('url')
|
||||||
|
item_aeskey = voice_info.get('aeskey', '')
|
||||||
|
voice_data = {
|
||||||
|
'url': download_url,
|
||||||
|
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||||
|
'filesize': voice_info.get('filesize') or voice_info.get('size'),
|
||||||
|
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
|
||||||
|
}
|
||||||
|
if voice_info.get('content'):
|
||||||
|
quote_data['content'] = voice_info.get('content')
|
||||||
|
# Same as private chat: append aeskey to url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
voice_data['url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
quote_data['voice'] = voice_data
|
||||||
|
elif quote_type == 'video':
|
||||||
|
video_info = quote_info.get('video', {}) or {}
|
||||||
|
download_url = video_info.get('url')
|
||||||
|
item_aeskey = video_info.get('aeskey', '')
|
||||||
|
video_data = {
|
||||||
|
'url': download_url,
|
||||||
|
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||||
|
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
|
||||||
|
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||||
|
'filename': video_info.get('filename') or video_info.get('name'),
|
||||||
|
}
|
||||||
|
# Same as private chat: append aeskey to download_url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
video_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
quote_data['video'] = video_data
|
||||||
|
elif quote_type == 'link':
|
||||||
|
quote_data['link'] = quote_info.get('link', {})
|
||||||
|
link = quote_data['link']
|
||||||
|
title = link.get('title', '')
|
||||||
|
desc = link.get('description') or link.get('digest', '')
|
||||||
|
quote_data['content'] = '\n'.join(filter(None, [title, desc]))
|
||||||
|
elif quote_type == 'mixed':
|
||||||
|
# Handle mixed type in quote (text + images + files etc.)
|
||||||
|
items = quote_info.get('mixed', {}).get('msg_item', [])
|
||||||
|
texts = []
|
||||||
|
images = []
|
||||||
|
files = []
|
||||||
|
for item in items:
|
||||||
|
item_type = item.get('msgtype')
|
||||||
|
if item_type == 'text':
|
||||||
|
texts.append(item.get('text', {}).get('content', ''))
|
||||||
|
elif item_type == 'image':
|
||||||
|
img_info = item.get('image', {})
|
||||||
|
img_url = img_info.get('url')
|
||||||
|
img_aeskey = img_info.get('aeskey', '')
|
||||||
|
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
|
||||||
|
if base64_data:
|
||||||
|
images.append(base64_data)
|
||||||
|
elif item_type == 'file':
|
||||||
|
file_info = item.get('file', {}) or {}
|
||||||
|
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||||
|
item_aeskey = file_info.get('aeskey', '')
|
||||||
|
file_data = {
|
||||||
|
'filename': file_info.get('filename') or file_info.get('name'),
|
||||||
|
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||||
|
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
|
||||||
|
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
|
||||||
|
'download_url': download_url,
|
||||||
|
'extra': file_info,
|
||||||
|
}
|
||||||
|
# Same as private chat: append aeskey to download_url for plugin processing
|
||||||
|
if download_url and item_aeskey:
|
||||||
|
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
|
||||||
|
files.append(file_data)
|
||||||
|
if texts:
|
||||||
|
quote_data['content'] = ' '.join(texts)
|
||||||
|
if images:
|
||||||
|
quote_data['images'] = images
|
||||||
|
quote_data['picurl'] = images[0]
|
||||||
|
if files:
|
||||||
|
quote_data['files'] = files
|
||||||
|
quote_data['file'] = files[0]
|
||||||
|
|
||||||
|
message_data['quote'] = quote_data
|
||||||
|
|
||||||
|
return message_data
|
||||||
|
|
||||||
|
|
||||||
class WecomBotClient:
|
class WecomBotClient:
|
||||||
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger):
|
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False):
|
||||||
"""企业微信智能机器人客户端。
|
"""企业微信智能机器人客户端。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -208,6 +736,7 @@ class WecomBotClient:
|
|||||||
EnCodingAESKey: 企业微信消息加解密密钥。
|
EnCodingAESKey: 企业微信消息加解密密钥。
|
||||||
Corpid: 企业 ID。
|
Corpid: 企业 ID。
|
||||||
logger: 日志记录器。
|
logger: 日志记录器。
|
||||||
|
unified_mode: 是否使用统一 webhook 模式(默认 False)。
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger)
|
>>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger)
|
||||||
@@ -217,10 +746,15 @@ class WecomBotClient:
|
|||||||
self.EnCodingAESKey = EnCodingAESKey
|
self.EnCodingAESKey = EnCodingAESKey
|
||||||
self.Corpid = Corpid
|
self.Corpid = Corpid
|
||||||
self.ReceiveId = ''
|
self.ReceiveId = ''
|
||||||
|
self.unified_mode = unified_mode
|
||||||
self.app = Quart(__name__)
|
self.app = Quart(__name__)
|
||||||
|
|
||||||
|
# 只有在非统一模式下才注册独立路由
|
||||||
|
if not self.unified_mode:
|
||||||
self.app.add_url_rule(
|
self.app.add_url_rule(
|
||||||
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['POST', 'GET']
|
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['POST', 'GET']
|
||||||
)
|
)
|
||||||
|
|
||||||
self._message_handlers = {
|
self._message_handlers = {
|
||||||
'example': [],
|
'example': [],
|
||||||
}
|
}
|
||||||
@@ -230,14 +764,27 @@ class WecomBotClient:
|
|||||||
self.stream_sessions = StreamSessionManager(logger=logger)
|
self.stream_sessions = StreamSessionManager(logger=logger)
|
||||||
self.stream_poll_timeout = 0.5
|
self.stream_poll_timeout = 0.5
|
||||||
|
|
||||||
|
self._feedback_callback: Optional[Callable] = None
|
||||||
|
|
||||||
|
def set_feedback_callback(self, callback: Callable) -> None:
|
||||||
|
"""设置反馈回调函数。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: 反馈回调函数,签名: async def callback(feedback_id, feedback_type, feedback_content, inaccurate_reasons, session)
|
||||||
|
"""
|
||||||
|
self._feedback_callback = callback
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]:
|
def _build_stream_payload(
|
||||||
|
stream_id: str, content: str, finish: bool, feedback_id: Optional[str] = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""按照企业微信协议拼装返回报文。
|
"""按照企业微信协议拼装返回报文。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
stream_id: 企业微信会话 ID。
|
stream_id: 企业微信会话 ID。
|
||||||
content: 推送的文本内容。
|
content: 推送的文本内容。
|
||||||
finish: 是否为最终片段。
|
finish: 是否为最终片段。
|
||||||
|
feedback_id: 反馈 ID,用于接收用户点赞/点踩反馈。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: 可直接加密返回的 payload。
|
dict[str, Any]: 可直接加密返回的 payload。
|
||||||
@@ -245,13 +792,16 @@ class WecomBotClient:
|
|||||||
Example:
|
Example:
|
||||||
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
|
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
|
||||||
"""
|
"""
|
||||||
return {
|
stream_payload = {
|
||||||
'msgtype': 'stream',
|
|
||||||
'stream': {
|
|
||||||
'id': stream_id,
|
'id': stream_id,
|
||||||
'finish': finish,
|
'finish': finish,
|
||||||
'content': content,
|
'content': content,
|
||||||
},
|
}
|
||||||
|
if feedback_id:
|
||||||
|
stream_payload['feedback'] = {'id': feedback_id}
|
||||||
|
return {
|
||||||
|
'msgtype': 'stream',
|
||||||
|
'stream': stream_payload,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
@@ -307,9 +857,14 @@ class WecomBotClient:
|
|||||||
"""
|
"""
|
||||||
session, is_new = self.stream_sessions.create_or_get(msg_json)
|
session, is_new = self.stream_sessions.create_or_get(msg_json)
|
||||||
|
|
||||||
|
feedback_id = str(uuid.uuid4())
|
||||||
|
session.feedback_id = feedback_id
|
||||||
|
self.stream_sessions.register_feedback_id(session.stream_id, feedback_id)
|
||||||
|
|
||||||
message_data = await self.get_message(msg_json)
|
message_data = await self.get_message(msg_json)
|
||||||
if message_data:
|
if message_data:
|
||||||
message_data['stream_id'] = session.stream_id
|
message_data['stream_id'] = session.stream_id
|
||||||
|
message_data['feedback_id'] = feedback_id
|
||||||
try:
|
try:
|
||||||
event = wecombotevent.WecomBotEvent(message_data)
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -318,7 +873,7 @@ class WecomBotClient:
|
|||||||
if is_new:
|
if is_new:
|
||||||
asyncio.create_task(self._dispatch_event(event))
|
asyncio.create_task(self._dispatch_event(event))
|
||||||
|
|
||||||
payload = self._build_stream_payload(session.stream_id, '', False)
|
payload = self._build_stream_payload(session.stream_id, '', False, feedback_id)
|
||||||
return await self._encrypt_and_reply(payload, nonce)
|
return await self._encrypt_and_reply(payload, nonce)
|
||||||
|
|
||||||
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
@@ -359,7 +914,7 @@ class WecomBotClient:
|
|||||||
return await self._encrypt_and_reply(payload, nonce)
|
return await self._encrypt_and_reply(payload, nonce)
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
"""企业微信回调入口。
|
"""企业微信回调入口(独立端口模式,使用全局 request)。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Quart Response: 根据请求类型返回验证、首包或刷新结果。
|
Quart Response: 根据请求类型返回验证、首包或刷新结果。
|
||||||
@@ -367,15 +922,33 @@ class WecomBotClient:
|
|||||||
Example:
|
Example:
|
||||||
作为 Quart 路由处理函数直接注册并使用。
|
作为 Quart 路由处理函数直接注册并使用。
|
||||||
"""
|
"""
|
||||||
|
return await self._handle_callback_internal(request)
|
||||||
|
|
||||||
|
async def handle_unified_webhook(self, req):
|
||||||
|
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req: Quart Request 对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
响应数据
|
||||||
|
"""
|
||||||
|
return await self._handle_callback_internal(req)
|
||||||
|
|
||||||
|
async def _handle_callback_internal(self, req):
|
||||||
|
"""处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req: Quart Request 对象
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '')
|
self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '')
|
||||||
await self.logger.info(f'{request.method} {request.url} {str(request.args)}')
|
|
||||||
|
|
||||||
if request.method == 'GET':
|
if req.method == 'GET':
|
||||||
return await self._handle_get_callback()
|
return await self._handle_get_callback(req)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if req.method == 'POST':
|
||||||
return await self._handle_post_callback()
|
return await self._handle_post_callback(req)
|
||||||
|
|
||||||
return Response('', status=405)
|
return Response('', status=405)
|
||||||
|
|
||||||
@@ -383,13 +956,13 @@ class WecomBotClient:
|
|||||||
await self.logger.error(traceback.format_exc())
|
await self.logger.error(traceback.format_exc())
|
||||||
return Response('Internal Server Error', status=500)
|
return Response('Internal Server Error', status=500)
|
||||||
|
|
||||||
async def _handle_get_callback(self) -> tuple[Response, int] | Response:
|
async def _handle_get_callback(self, req) -> tuple[Response, int] | Response:
|
||||||
"""处理企业微信的 GET 验证请求。"""
|
"""处理企业微信的 GET 验证请求。"""
|
||||||
|
|
||||||
msg_signature = unquote(request.args.get('msg_signature', ''))
|
msg_signature = unquote(req.args.get('msg_signature', ''))
|
||||||
timestamp = unquote(request.args.get('timestamp', ''))
|
timestamp = unquote(req.args.get('timestamp', ''))
|
||||||
nonce = unquote(request.args.get('nonce', ''))
|
nonce = unquote(req.args.get('nonce', ''))
|
||||||
echostr = unquote(request.args.get('echostr', ''))
|
echostr = unquote(req.args.get('echostr', ''))
|
||||||
|
|
||||||
if not all([msg_signature, timestamp, nonce, echostr]):
|
if not all([msg_signature, timestamp, nonce, echostr]):
|
||||||
await self.logger.error('请求参数缺失')
|
await self.logger.error('请求参数缺失')
|
||||||
@@ -402,16 +975,16 @@ class WecomBotClient:
|
|||||||
|
|
||||||
return Response(decrypted_str, mimetype='text/plain')
|
return Response(decrypted_str, mimetype='text/plain')
|
||||||
|
|
||||||
async def _handle_post_callback(self) -> tuple[Response, int] | Response:
|
async def _handle_post_callback(self, req) -> tuple[Response, int] | Response:
|
||||||
"""处理企业微信的 POST 回调请求。"""
|
"""处理企业微信的 POST 回调请求。"""
|
||||||
|
|
||||||
self.stream_sessions.cleanup()
|
self.stream_sessions.cleanup()
|
||||||
|
|
||||||
msg_signature = unquote(request.args.get('msg_signature', ''))
|
msg_signature = unquote(req.args.get('msg_signature', ''))
|
||||||
timestamp = unquote(request.args.get('timestamp', ''))
|
timestamp = unquote(req.args.get('timestamp', ''))
|
||||||
nonce = unquote(request.args.get('nonce', ''))
|
nonce = unquote(req.args.get('nonce', ''))
|
||||||
|
|
||||||
encrypted_json = await request.get_json()
|
encrypted_json = await req.get_json()
|
||||||
encrypted_msg = (encrypted_json or {}).get('encrypt', '')
|
encrypted_msg = (encrypted_json or {}).get('encrypt', '')
|
||||||
if not encrypted_msg:
|
if not encrypted_msg:
|
||||||
await self.logger.error("请求体中缺少 'encrypt' 字段")
|
await self.logger.error("请求体中缺少 'encrypt' 字段")
|
||||||
@@ -425,60 +998,83 @@ class WecomBotClient:
|
|||||||
|
|
||||||
msg_json = json.loads(decrypted_xml)
|
msg_json = json.loads(decrypted_xml)
|
||||||
|
|
||||||
|
event = msg_json.get('event', {})
|
||||||
|
event_type = event.get('eventtype', '')
|
||||||
|
|
||||||
|
if event_type == 'feedback_event':
|
||||||
|
return await self._handle_feedback_event(msg_json, nonce)
|
||||||
|
|
||||||
if msg_json.get('msgtype') == 'stream':
|
if msg_json.get('msgtype') == 'stream':
|
||||||
return await self._handle_post_followup_response(msg_json, nonce)
|
return await self._handle_post_followup_response(msg_json, nonce)
|
||||||
|
|
||||||
return await self._handle_post_initial_response(msg_json, nonce)
|
return await self._handle_post_initial_response(msg_json, nonce)
|
||||||
|
|
||||||
async def get_message(self, msg_json):
|
async def _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
message_data = {}
|
"""处理企业微信用户反馈事件(点赞/点踩)。
|
||||||
|
|
||||||
if msg_json.get('chattype', '') == 'single':
|
Args:
|
||||||
message_data['type'] = 'single'
|
msg_json: 解密后的企业微信反馈事件 JSON。
|
||||||
elif msg_json.get('chattype', '') == 'group':
|
nonce: 企业微信回调参数 nonce。
|
||||||
message_data['type'] = 'group'
|
|
||||||
|
|
||||||
if msg_json.get('msgtype') == 'text':
|
Returns:
|
||||||
message_data['content'] = msg_json.get('text', {}).get('content')
|
Tuple[Response, int]: Quart Response 及状态码。
|
||||||
elif msg_json.get('msgtype') == 'image':
|
|
||||||
picurl = msg_json.get('image', {}).get('url', '')
|
|
||||||
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
|
|
||||||
message_data['picurl'] = base64
|
|
||||||
elif msg_json.get('msgtype') == 'mixed':
|
|
||||||
items = msg_json.get('mixed', {}).get('msg_item', [])
|
|
||||||
texts = []
|
|
||||||
picurl = None
|
|
||||||
for item in items:
|
|
||||||
if item.get('msgtype') == 'text':
|
|
||||||
texts.append(item.get('text', {}).get('content', ''))
|
|
||||||
elif item.get('msgtype') == 'image' and picurl is None:
|
|
||||||
picurl = item.get('image', {}).get('url')
|
|
||||||
|
|
||||||
if texts:
|
Note:
|
||||||
message_data['content'] = ''.join(texts) # 拼接所有 text
|
企业微信协议要求:反馈事件目前仅支持回复空包。
|
||||||
if picurl:
|
"""
|
||||||
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
|
try:
|
||||||
message_data['picurl'] = base64 # 只保留第一个 image
|
feedback_event = msg_json.get('event', {}).get('feedback_event', {})
|
||||||
|
feedback_id = feedback_event.get('id', '')
|
||||||
|
feedback_type = feedback_event.get('type', 0)
|
||||||
|
feedback_content = feedback_event.get('content', '')
|
||||||
|
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
|
||||||
|
|
||||||
# Extract user information
|
await self.logger.info(
|
||||||
from_info = msg_json.get('from', {})
|
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
|
||||||
message_data['userid'] = from_info.get('userid', '')
|
f'content={feedback_content}, reasons={inaccurate_reasons}'
|
||||||
message_data['username'] = (
|
|
||||||
from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract chat/group information
|
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
||||||
if msg_json.get('chattype', '') == 'group':
|
|
||||||
message_data['chatid'] = msg_json.get('chatid', '')
|
|
||||||
# Try to get group name if available
|
|
||||||
message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '')
|
|
||||||
|
|
||||||
message_data['msgid'] = msg_json.get('msgid', '')
|
if session:
|
||||||
|
await self.logger.info(
|
||||||
|
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话,仍将记录反馈')
|
||||||
|
|
||||||
if msg_json.get('aibotid'):
|
# Dispatch feedback event regardless of session availability
|
||||||
message_data['aibotid'] = msg_json.get('aibotid', '')
|
for handler in self._message_handlers.get('feedback', []):
|
||||||
|
try:
|
||||||
|
await handler(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=inaccurate_reasons,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(traceback.format_exc())
|
||||||
|
|
||||||
return message_data
|
if self._feedback_callback:
|
||||||
|
try:
|
||||||
|
await self._feedback_callback(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=inaccurate_reasons,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
return await self._encrypt_and_reply({}, nonce)
|
||||||
|
|
||||||
|
async def get_message(self, msg_json):
|
||||||
|
return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
|
||||||
|
|
||||||
async def _handle_message(self, event: wecombotevent.WecomBotEvent):
|
async def _handle_message(self, event: wecombotevent.WecomBotEvent):
|
||||||
"""
|
"""
|
||||||
@@ -545,41 +1141,21 @@ class WecomBotClient:
|
|||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
def on_feedback(self):
|
||||||
|
def decorator(func: Callable):
|
||||||
|
if 'feedback' not in self._message_handlers:
|
||||||
|
self._message_handlers['feedback'] = []
|
||||||
|
self._message_handlers['feedback'].append(func)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
||||||
async with httpx.AsyncClient() as client:
|
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
||||||
response = await client.get(download_url)
|
if data:
|
||||||
if response.status_code != 200:
|
return _bytes_to_data_uri(data)
|
||||||
await self.logger.error(f'failed to get file: {response.text}')
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
encrypted_bytes = response.content
|
|
||||||
|
|
||||||
aes_key = base64.b64decode(encoding_aes_key + '=') # base64 补齐
|
|
||||||
iv = aes_key[:16]
|
|
||||||
|
|
||||||
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
|
||||||
decrypted = cipher.decrypt(encrypted_bytes)
|
|
||||||
|
|
||||||
pad_len = decrypted[-1]
|
|
||||||
decrypted = decrypted[:-pad_len]
|
|
||||||
|
|
||||||
if decrypted.startswith(b'\xff\xd8'): # JPEG
|
|
||||||
mime_type = 'image/jpeg'
|
|
||||||
elif decrypted.startswith(b'\x89PNG'): # PNG
|
|
||||||
mime_type = 'image/png'
|
|
||||||
elif decrypted.startswith((b'GIF87a', b'GIF89a')): # GIF
|
|
||||||
mime_type = 'image/gif'
|
|
||||||
elif decrypted.startswith(b'BM'): # BMP
|
|
||||||
mime_type = 'image/bmp'
|
|
||||||
elif decrypted.startswith(b'II*\x00') or decrypted.startswith(b'MM\x00*'): # TIFF
|
|
||||||
mime_type = 'image/tiff'
|
|
||||||
else:
|
|
||||||
mime_type = 'application/octet-stream'
|
|
||||||
|
|
||||||
# 转 base64
|
|
||||||
base64_str = base64.b64encode(decrypted).decode('utf-8')
|
|
||||||
return f'data:{mime_type};base64,{base64_str}'
|
|
||||||
|
|
||||||
async def run_task(self, host: str, port: int, *args, **kwargs):
|
async def run_task(self, host: str, port: int, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
启动 Quart 应用。
|
启动 Quart 应用。
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ class WecomBotEvent(dict):
|
|||||||
"""
|
"""
|
||||||
return self.get('type', '')
|
return self.get('type', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def msgtype(self) -> str:
|
||||||
|
"""
|
||||||
|
消息 msgtype
|
||||||
|
"""
|
||||||
|
return self.get('msgtype', '')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def userid(self) -> str:
|
def userid(self) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -57,6 +64,55 @@ class WecomBotEvent(dict):
|
|||||||
"""
|
"""
|
||||||
return self.get('picurl', '')
|
return self.get('picurl', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def images(self):
|
||||||
|
"""
|
||||||
|
图片列表(兼容 mixed)
|
||||||
|
"""
|
||||||
|
return self.get('images', [])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file(self):
|
||||||
|
"""
|
||||||
|
文件信息
|
||||||
|
"""
|
||||||
|
return self.get('file', {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def voice(self):
|
||||||
|
"""
|
||||||
|
语音信息
|
||||||
|
"""
|
||||||
|
return self.get('voice', {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def video(self):
|
||||||
|
"""
|
||||||
|
视频信息
|
||||||
|
"""
|
||||||
|
return self.get('video', {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def link(self):
|
||||||
|
"""
|
||||||
|
链接消息信息
|
||||||
|
"""
|
||||||
|
return self.get('link', {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def location(self):
|
||||||
|
"""
|
||||||
|
位置信息
|
||||||
|
"""
|
||||||
|
return self.get('location', {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attachments(self):
|
||||||
|
"""
|
||||||
|
原始 mixed 中的附件项
|
||||||
|
"""
|
||||||
|
return self.get('attachments', [])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def chatid(self) -> str:
|
def chatid(self) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -77,3 +133,24 @@ class WecomBotEvent(dict):
|
|||||||
AI Bot ID
|
AI Bot ID
|
||||||
"""
|
"""
|
||||||
return self.get('aibotid', '')
|
return self.get('aibotid', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def feedback_id(self) -> str:
|
||||||
|
"""
|
||||||
|
反馈 ID,用于关联用户点赞/点踩反馈
|
||||||
|
"""
|
||||||
|
return self.get('feedback_id', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stream_id(self) -> str:
|
||||||
|
"""
|
||||||
|
流式消息 ID
|
||||||
|
"""
|
||||||
|
return self.get('stream_id', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quote(self):
|
||||||
|
"""
|
||||||
|
引用消息信息(群聊中用户引用其他消息时返回)
|
||||||
|
"""
|
||||||
|
return self.get('quote', {})
|
||||||
|
|||||||
685
src/langbot/libs/wecom_ai_bot_api/ws_client.py
Normal file
685
src/langbot/libs/wecom_ai_bot_api/ws_client.py
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
"""WeChat Work AI Bot WebSocket long connection client.
|
||||||
|
|
||||||
|
Implements the WebSocket protocol for receiving messages and sending replies
|
||||||
|
via a persistent connection to wss://openws.work.weixin.qq.com, as an
|
||||||
|
alternative to the HTTP callback (webhook) mode.
|
||||||
|
|
||||||
|
Protocol reference: https://developer.work.weixin.qq.com/document/path/101463
|
||||||
|
Official Node.js SDK: https://github.com/WecomTeam/aibot-node-sdk
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||||
|
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
|
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||||
|
|
||||||
|
# WebSocket frame command constants
|
||||||
|
CMD_SUBSCRIBE = 'aibot_subscribe'
|
||||||
|
CMD_HEARTBEAT = 'ping'
|
||||||
|
CMD_MSG_CALLBACK = 'aibot_msg_callback'
|
||||||
|
CMD_EVENT_CALLBACK = 'aibot_event_callback'
|
||||||
|
CMD_RESPOND_MSG = 'aibot_respond_msg'
|
||||||
|
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
|
||||||
|
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
|
||||||
|
CMD_SEND_MSG = 'aibot_send_msg'
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_req_id(prefix: str) -> str:
|
||||||
|
"""Generate a unique request ID in the format: {prefix}_{timestamp}_{random}."""
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
|
rand = secrets.token_hex(4)
|
||||||
|
return f'{prefix}_{ts}_{rand}'
|
||||||
|
|
||||||
|
|
||||||
|
class WecomBotWsClient:
|
||||||
|
"""WeChat Work AI Bot WebSocket long connection client.
|
||||||
|
|
||||||
|
Provides message receiving, streaming reply, proactive message sending,
|
||||||
|
and event callback handling over a persistent WebSocket connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
bot_id: str,
|
||||||
|
secret: str,
|
||||||
|
logger: EventLogger,
|
||||||
|
encoding_aes_key: str = '',
|
||||||
|
ws_url: str = DEFAULT_WS_URL,
|
||||||
|
heartbeat_interval: float = 30.0,
|
||||||
|
max_reconnect_attempts: int = -1,
|
||||||
|
reconnect_base_delay: float = 1.0,
|
||||||
|
reconnect_max_delay: float = 30.0,
|
||||||
|
):
|
||||||
|
self.bot_id = bot_id
|
||||||
|
self.secret = secret
|
||||||
|
self.logger = logger
|
||||||
|
self.encoding_aes_key = encoding_aes_key
|
||||||
|
self.ws_url = ws_url
|
||||||
|
self.heartbeat_interval = heartbeat_interval
|
||||||
|
self.max_reconnect_attempts = max_reconnect_attempts
|
||||||
|
self.reconnect_base_delay = reconnect_base_delay
|
||||||
|
self.reconnect_max_delay = reconnect_max_delay
|
||||||
|
|
||||||
|
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
|
||||||
|
self._session: Optional[aiohttp.ClientSession] = None
|
||||||
|
self._running = False
|
||||||
|
self._heartbeat_task: Optional[asyncio.Task] = None
|
||||||
|
self._missed_pong_count = 0
|
||||||
|
self._max_missed_pong = 2
|
||||||
|
self._reconnect_attempts = 0
|
||||||
|
|
||||||
|
# Message handler registry (same pattern as WecomBotClient)
|
||||||
|
self._message_handlers: dict[str, list[Callable]] = {}
|
||||||
|
# Message deduplication
|
||||||
|
self._msg_id_map: dict[str, int] = {}
|
||||||
|
|
||||||
|
# Pending ACK futures: req_id -> Future[dict]
|
||||||
|
self._pending_acks: dict[str, asyncio.Future] = {}
|
||||||
|
# Per-req_id serial reply queues
|
||||||
|
self._reply_queues: dict[str, asyncio.Queue] = {}
|
||||||
|
self._reply_workers: dict[str, asyncio.Task] = {}
|
||||||
|
self._reply_ack_timeout = 5.0
|
||||||
|
|
||||||
|
# Stream ID tracking for WebSocket mode
|
||||||
|
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
|
||||||
|
# Dedup: skip sending when content hasn't changed
|
||||||
|
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
|
||||||
|
# Stream session info for feedback tracking
|
||||||
|
self._stream_sessions: dict[str, dict] = {} # msg_id -> session info
|
||||||
|
# Feedback tracking: feedback_id -> session info
|
||||||
|
self._feedback_sessions: dict[str, dict] = {} # feedback_id -> {msg_id, user_id, chat_id, stream_id, req_id}
|
||||||
|
# msg_id -> feedback_id (for associating feedback with message)
|
||||||
|
self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id
|
||||||
|
|
||||||
|
# ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Connect to WebSocket server with automatic reconnection.
|
||||||
|
|
||||||
|
This method blocks until disconnect() is called or max reconnect
|
||||||
|
attempts are exhausted.
|
||||||
|
"""
|
||||||
|
self._running = True
|
||||||
|
self._reconnect_attempts = 0
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
await self._connect_once()
|
||||||
|
except Exception:
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
await self.logger.error(f'WebSocket connection error: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Reconnect with exponential backoff
|
||||||
|
if self.max_reconnect_attempts != -1 and self._reconnect_attempts >= self.max_reconnect_attempts:
|
||||||
|
await self.logger.error(f'Max reconnect attempts reached ({self.max_reconnect_attempts}), giving up')
|
||||||
|
break
|
||||||
|
|
||||||
|
self._reconnect_attempts += 1
|
||||||
|
delay = min(
|
||||||
|
self.reconnect_base_delay * (2 ** (self._reconnect_attempts - 1)),
|
||||||
|
self.reconnect_max_delay,
|
||||||
|
)
|
||||||
|
await self.logger.info(f'Reconnecting in {delay:.1f}s (attempt {self._reconnect_attempts})...')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
"""Gracefully disconnect from the WebSocket server."""
|
||||||
|
self._running = False
|
||||||
|
if self._heartbeat_task and not self._heartbeat_task.done():
|
||||||
|
self._heartbeat_task.cancel()
|
||||||
|
for task in self._reply_workers.values():
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
if self._ws and not self._ws.closed:
|
||||||
|
await self._ws.close()
|
||||||
|
self._ws = None
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
self._session = None
|
||||||
|
|
||||||
|
def on_message(self, msg_type: str) -> Callable:
|
||||||
|
"""Decorator to register a message handler.
|
||||||
|
|
||||||
|
Same interface as WecomBotClient.on_message for compatibility.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg_type: 'single', 'group', or specific message type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func: Callable[[wecombotevent.WecomBotEvent], Any]):
|
||||||
|
if msg_type not in self._message_handlers:
|
||||||
|
self._message_handlers[msg_type] = []
|
||||||
|
self._message_handlers[msg_type].append(func)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
def on_feedback(self) -> Callable:
|
||||||
|
"""Decorator to register a feedback event handler.
|
||||||
|
|
||||||
|
Same interface as WecomBotClient.on_feedback for compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func: Callable):
|
||||||
|
if 'feedback' not in self._message_handlers:
|
||||||
|
self._message_handlers['feedback'] = []
|
||||||
|
self._message_handlers['feedback'].append(func)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
async def reply_stream(
|
||||||
|
self,
|
||||||
|
req_id: str,
|
||||||
|
stream_id: str,
|
||||||
|
content: str,
|
||||||
|
finish: bool = False,
|
||||||
|
feedback_id: str = '',
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Send a streaming reply frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req_id: The req_id from the original message frame (must be passed through).
|
||||||
|
stream_id: The stream ID for this streaming session.
|
||||||
|
content: The content to send (supports Markdown).
|
||||||
|
finish: Whether this is the final chunk.
|
||||||
|
feedback_id: Optional feedback ID for receiving user feedback (like/dislike).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ACK frame dict, or None on failure.
|
||||||
|
"""
|
||||||
|
stream_payload = {
|
||||||
|
'id': stream_id,
|
||||||
|
'finish': finish,
|
||||||
|
'content': content,
|
||||||
|
}
|
||||||
|
if feedback_id:
|
||||||
|
stream_payload['feedback'] = {'id': feedback_id}
|
||||||
|
|
||||||
|
body = {
|
||||||
|
'msgtype': 'stream',
|
||||||
|
'stream': stream_payload,
|
||||||
|
}
|
||||||
|
return await self._send_reply(req_id, body)
|
||||||
|
|
||||||
|
async def reply_text(self, req_id: str, content: str) -> Optional[dict]:
|
||||||
|
"""Send a non-streaming text reply.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req_id: The req_id from the original message frame.
|
||||||
|
content: The text content to reply.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ACK frame dict, or None on failure.
|
||||||
|
"""
|
||||||
|
body = {
|
||||||
|
'msgtype': 'markdown',
|
||||||
|
'markdown': {
|
||||||
|
'content': content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return await self._send_reply(req_id, body)
|
||||||
|
|
||||||
|
async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdown') -> Optional[dict]:
|
||||||
|
"""Proactively send a message to a specified chat.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chat_id: The chat ID (userid for single chat, chatid for group chat).
|
||||||
|
content: The message content.
|
||||||
|
msgtype: Message type, 'markdown' by default.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ACK frame dict, or None on failure.
|
||||||
|
"""
|
||||||
|
req_id = _generate_req_id(CMD_SEND_MSG)
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
'chatid': chat_id,
|
||||||
|
'msgtype': msgtype,
|
||||||
|
}
|
||||||
|
if msgtype == 'markdown':
|
||||||
|
body['markdown'] = {'content': content}
|
||||||
|
elif msgtype == 'text':
|
||||||
|
body['text'] = {'content': content}
|
||||||
|
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
||||||
|
|
||||||
|
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
|
||||||
|
"""Push a streaming chunk for a given message ID.
|
||||||
|
|
||||||
|
Compatible interface with WecomBotClient.push_stream_chunk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg_id: The original message ID.
|
||||||
|
content: The cumulative content from the pipeline.
|
||||||
|
is_final: Whether this is the final chunk.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the stream session exists and chunk was sent.
|
||||||
|
"""
|
||||||
|
key = self._stream_ids.get(msg_id)
|
||||||
|
if not key:
|
||||||
|
return False
|
||||||
|
req_id, stream_id = key.split('|', 1)
|
||||||
|
try:
|
||||||
|
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
|
||||||
|
if not is_final and content == self._stream_last_content.get(msg_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Generate feedback_id for final chunk
|
||||||
|
feedback_id = ''
|
||||||
|
if is_final:
|
||||||
|
feedback_id = _generate_req_id('feedback')
|
||||||
|
self._msg_feedback_ids[msg_id] = feedback_id
|
||||||
|
# Store session info for feedback tracking
|
||||||
|
session_info = self._stream_sessions.get(msg_id)
|
||||||
|
if session_info:
|
||||||
|
self._feedback_sessions[feedback_id] = session_info
|
||||||
|
|
||||||
|
await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id)
|
||||||
|
self._stream_last_content[msg_id] = content
|
||||||
|
if is_final:
|
||||||
|
self._stream_ids.pop(msg_id, None)
|
||||||
|
self._stream_last_content.pop(msg_id, None)
|
||||||
|
self._stream_sessions.pop(msg_id, None)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def set_message(self, msg_id: str, content: str):
|
||||||
|
"""Fallback: send content as a final stream chunk or direct reply.
|
||||||
|
|
||||||
|
Compatible interface with WecomBotClient.set_message.
|
||||||
|
"""
|
||||||
|
handled = await self.push_stream_chunk(msg_id, content, is_final=True)
|
||||||
|
if not handled:
|
||||||
|
await self.logger.warning(f'No active stream for msg_id={msg_id}, message dropped')
|
||||||
|
|
||||||
|
# ── Connection lifecycle ────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _connect_once(self):
|
||||||
|
"""Establish a single WebSocket connection, authenticate, and listen."""
|
||||||
|
await self.logger.info(f'Connecting to {self.ws_url}...')
|
||||||
|
|
||||||
|
self._session = aiohttp.ClientSession()
|
||||||
|
try:
|
||||||
|
self._ws = await self._session.ws_connect(self.ws_url)
|
||||||
|
self._missed_pong_count = 0
|
||||||
|
self._reconnect_attempts = 0
|
||||||
|
await self.logger.info('WebSocket connected, sending auth...')
|
||||||
|
|
||||||
|
await self._send_auth()
|
||||||
|
|
||||||
|
# Wait for auth response
|
||||||
|
auth_ok = await self._wait_for_auth()
|
||||||
|
if not auth_ok:
|
||||||
|
await self.logger.error('Authentication failed')
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.logger.info('Authenticated successfully')
|
||||||
|
|
||||||
|
# Start heartbeat
|
||||||
|
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._listen_loop()
|
||||||
|
finally:
|
||||||
|
if self._heartbeat_task and not self._heartbeat_task.done():
|
||||||
|
self._heartbeat_task.cancel()
|
||||||
|
self._clear_pending_acks('Connection closed')
|
||||||
|
finally:
|
||||||
|
if self._ws and not self._ws.closed:
|
||||||
|
await self._ws.close()
|
||||||
|
self._ws = None
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
self._session = None
|
||||||
|
|
||||||
|
async def _send_auth(self):
|
||||||
|
"""Send the authentication frame."""
|
||||||
|
frame = {
|
||||||
|
'cmd': CMD_SUBSCRIBE,
|
||||||
|
'headers': {'req_id': _generate_req_id(CMD_SUBSCRIBE)},
|
||||||
|
'body': {
|
||||||
|
'bot_id': self.bot_id,
|
||||||
|
'secret': self.secret,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await self._send_frame(frame)
|
||||||
|
|
||||||
|
async def _wait_for_auth(self) -> bool:
|
||||||
|
"""Wait for and validate the authentication response."""
|
||||||
|
try:
|
||||||
|
msg = await asyncio.wait_for(self._ws.receive(), timeout=10.0)
|
||||||
|
if msg.type in (aiohttp.WSMsgType.TEXT,):
|
||||||
|
frame = json.loads(msg.data)
|
||||||
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
if req_id.startswith(CMD_SUBSCRIBE) and frame.get('errcode') == 0:
|
||||||
|
return True
|
||||||
|
await self.logger.error(f'Auth response: errcode={frame.get("errcode")}, errmsg={frame.get("errmsg")}')
|
||||||
|
return False
|
||||||
|
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):
|
||||||
|
await self.logger.error(f'WebSocket closed during auth: {msg.type}')
|
||||||
|
return False
|
||||||
|
await self.logger.error(f'Unexpected message type during auth: {msg.type}')
|
||||||
|
return False
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await self.logger.error('Auth response timeout')
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _heartbeat_loop(self):
|
||||||
|
"""Periodically send heartbeat pings."""
|
||||||
|
try:
|
||||||
|
while self._running and self._ws and not self._ws.closed:
|
||||||
|
await asyncio.sleep(self.heartbeat_interval)
|
||||||
|
if not self._running or not self._ws or self._ws.closed:
|
||||||
|
break
|
||||||
|
|
||||||
|
if self._missed_pong_count >= self._max_missed_pong:
|
||||||
|
await self.logger.warning(
|
||||||
|
f'No heartbeat ack for {self._missed_pong_count} consecutive pings, connection considered dead'
|
||||||
|
)
|
||||||
|
await self._ws.close()
|
||||||
|
break
|
||||||
|
|
||||||
|
self._missed_pong_count += 1
|
||||||
|
frame = {
|
||||||
|
'cmd': CMD_HEARTBEAT,
|
||||||
|
'headers': {'req_id': _generate_req_id(CMD_HEARTBEAT)},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await self._send_frame(frame)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _listen_loop(self):
|
||||||
|
"""Listen for incoming WebSocket frames and dispatch them."""
|
||||||
|
async for msg in self._ws:
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||||
|
try:
|
||||||
|
frame = json.loads(msg.data)
|
||||||
|
await self._handle_frame(frame)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await self.logger.error(f'Failed to parse WebSocket message: {str(msg.data)[:200]}')
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling frame: {traceback.format_exc()}')
|
||||||
|
elif msg.type == aiohttp.WSMsgType.BINARY:
|
||||||
|
try:
|
||||||
|
frame = json.loads(msg.data)
|
||||||
|
await self._handle_frame(frame)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling binary frame: {traceback.format_exc()}')
|
||||||
|
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):
|
||||||
|
await self.logger.warning(f'WebSocket connection closed: {msg.type}')
|
||||||
|
break
|
||||||
|
|
||||||
|
# ── Frame handling ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _handle_frame(self, frame: dict):
|
||||||
|
"""Route an incoming frame to the appropriate handler."""
|
||||||
|
cmd = frame.get('cmd', '')
|
||||||
|
|
||||||
|
# Message push
|
||||||
|
if cmd == CMD_MSG_CALLBACK:
|
||||||
|
asyncio.create_task(self._handle_message_callback(frame))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Event push
|
||||||
|
if cmd == CMD_EVENT_CALLBACK:
|
||||||
|
asyncio.create_task(self._handle_event_callback(frame))
|
||||||
|
return
|
||||||
|
|
||||||
|
# No cmd → response/ACK frame, dispatch by req_id prefix
|
||||||
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
|
||||||
|
# Check pending ACKs first
|
||||||
|
if req_id in self._pending_acks:
|
||||||
|
future = self._pending_acks.pop(req_id)
|
||||||
|
if not future.done():
|
||||||
|
future.set_result(frame)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Heartbeat response
|
||||||
|
if req_id.startswith(CMD_HEARTBEAT):
|
||||||
|
if frame.get('errcode') == 0:
|
||||||
|
self._missed_pong_count = 0
|
||||||
|
return
|
||||||
|
|
||||||
|
# Unknown frame
|
||||||
|
await self.logger.warning(f'Unknown frame: {json.dumps(frame, ensure_ascii=False)[:200]}')
|
||||||
|
|
||||||
|
async def _handle_message_callback(self, frame: dict):
|
||||||
|
"""Handle an incoming message callback frame."""
|
||||||
|
try:
|
||||||
|
body = frame.get('body', {})
|
||||||
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
|
||||||
|
# Parse message using shared logic
|
||||||
|
message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger)
|
||||||
|
if not message_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Generate stream_id for this message and store the mapping
|
||||||
|
stream_id = _generate_req_id('stream')
|
||||||
|
msg_id = message_data.get('msgid', '')
|
||||||
|
if msg_id:
|
||||||
|
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
|
||||||
|
# Store session info for feedback tracking
|
||||||
|
self._stream_sessions[msg_id] = {
|
||||||
|
'req_id': req_id,
|
||||||
|
'stream_id': stream_id,
|
||||||
|
'msg_id': msg_id,
|
||||||
|
'user_id': message_data.get('userid', ''),
|
||||||
|
'chat_id': message_data.get('chatid', ''),
|
||||||
|
'chat_type': message_data.get('type', 'single'),
|
||||||
|
}
|
||||||
|
message_data['stream_id'] = stream_id
|
||||||
|
message_data['req_id'] = req_id
|
||||||
|
|
||||||
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
|
await self._dispatch_event(event)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
async def _handle_event_callback(self, frame: dict):
|
||||||
|
"""Handle an incoming event callback frame (enter_chat, template_card_event, feedback_event, disconnected_event)."""
|
||||||
|
try:
|
||||||
|
body = frame.get('body', {})
|
||||||
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
|
||||||
|
event_info = body.get('event', {})
|
||||||
|
event_type = event_info.get('eventtype', '')
|
||||||
|
|
||||||
|
message_data = {
|
||||||
|
'msgtype': 'event',
|
||||||
|
'type': body.get('chattype', 'single'),
|
||||||
|
'event': event_info,
|
||||||
|
'eventtype': event_type,
|
||||||
|
'msgid': body.get('msgid', ''),
|
||||||
|
'aibotid': body.get('aibotid', ''),
|
||||||
|
'req_id': req_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
from_info = body.get('from', {})
|
||||||
|
message_data['userid'] = from_info.get('userid', '')
|
||||||
|
message_data['username'] = from_info.get('alias', '') or from_info.get('userid', '')
|
||||||
|
|
||||||
|
if body.get('chatid'):
|
||||||
|
message_data['chatid'] = body.get('chatid', '')
|
||||||
|
|
||||||
|
if event_type == 'feedback_event':
|
||||||
|
feedback_event = event_info.get('feedback_event', {})
|
||||||
|
feedback_id = feedback_event.get('id', '')
|
||||||
|
feedback_type = feedback_event.get('type', 0)
|
||||||
|
feedback_content = feedback_event.get('content', '')
|
||||||
|
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
|
||||||
|
|
||||||
|
await self.logger.info(
|
||||||
|
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
|
||||||
|
f'content={feedback_content}, reasons={inaccurate_reasons}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look up session by feedback_id
|
||||||
|
session_info = self._feedback_sessions.get(feedback_id)
|
||||||
|
session = None
|
||||||
|
if session_info:
|
||||||
|
session = StreamSession(
|
||||||
|
stream_id=session_info.get('stream_id', ''),
|
||||||
|
msg_id=session_info.get('msg_id', ''),
|
||||||
|
chat_id=session_info.get('chat_id') or None,
|
||||||
|
user_id=session_info.get('user_id') or None,
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
)
|
||||||
|
await self.logger.info(
|
||||||
|
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
|
||||||
|
|
||||||
|
for handler in self._message_handlers.get('feedback', []):
|
||||||
|
try:
|
||||||
|
await handler(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=inaccurate_reasons,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}')
|
||||||
|
return
|
||||||
|
|
||||||
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
|
|
||||||
|
if event_type in self._message_handlers:
|
||||||
|
for handler in self._message_handlers[event_type]:
|
||||||
|
await handler(event)
|
||||||
|
|
||||||
|
if 'event' in self._message_handlers:
|
||||||
|
for handler in self._message_handlers['event']:
|
||||||
|
await handler(event)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in event callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
async def _dispatch_event(self, event: wecombotevent.WecomBotEvent):
|
||||||
|
"""Dispatch a message event to registered handlers with deduplication."""
|
||||||
|
try:
|
||||||
|
message_id = event.message_id
|
||||||
|
if message_id in self._msg_id_map:
|
||||||
|
self._msg_id_map[message_id] += 1
|
||||||
|
return
|
||||||
|
self._msg_id_map[message_id] = 1
|
||||||
|
|
||||||
|
msg_type = event.type
|
||||||
|
if msg_type in self._message_handlers:
|
||||||
|
for handler in self._message_handlers[msg_type]:
|
||||||
|
await handler(event)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error dispatching event: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
# ── Reply sending with serial queue ─────────────────────────────
|
||||||
|
|
||||||
|
async def _send_reply(
|
||||||
|
self,
|
||||||
|
req_id: str,
|
||||||
|
body: dict,
|
||||||
|
cmd: str = CMD_RESPOND_MSG,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Send a reply frame and wait for ACK.
|
||||||
|
|
||||||
|
Replies with the same req_id are serialized to maintain ordering.
|
||||||
|
"""
|
||||||
|
if not self._ws or self._ws.closed:
|
||||||
|
return None
|
||||||
|
|
||||||
|
frame = {
|
||||||
|
'cmd': cmd,
|
||||||
|
'headers': {'req_id': req_id},
|
||||||
|
'body': body,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure serial delivery per req_id
|
||||||
|
if req_id not in self._reply_queues:
|
||||||
|
self._reply_queues[req_id] = asyncio.Queue()
|
||||||
|
self._reply_workers[req_id] = asyncio.create_task(self._reply_queue_worker(req_id))
|
||||||
|
|
||||||
|
future: asyncio.Future = asyncio.get_event_loop().create_future()
|
||||||
|
await self._reply_queues[req_id].put((frame, future))
|
||||||
|
return await future
|
||||||
|
|
||||||
|
async def _reply_queue_worker(self, req_id: str):
|
||||||
|
"""Process reply queue items serially for a given req_id."""
|
||||||
|
queue = self._reply_queues[req_id]
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
frame, future = await asyncio.wait_for(queue.get(), timeout=60.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Queue idle, clean up worker
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
ack = await self._send_and_wait_ack(frame)
|
||||||
|
if not future.done():
|
||||||
|
future.set_result(ack)
|
||||||
|
except Exception as e:
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(e)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._reply_queues.pop(req_id, None)
|
||||||
|
self._reply_workers.pop(req_id, None)
|
||||||
|
|
||||||
|
async def _send_and_wait_ack(self, frame: dict) -> Optional[dict]:
|
||||||
|
"""Send a frame and wait for the corresponding ACK."""
|
||||||
|
req_id = frame['headers']['req_id']
|
||||||
|
ack_future: asyncio.Future = asyncio.get_event_loop().create_future()
|
||||||
|
self._pending_acks[req_id] = ack_future
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._send_frame(frame)
|
||||||
|
result = await asyncio.wait_for(ack_future, timeout=self._reply_ack_timeout)
|
||||||
|
if result.get('errcode', 0) != 0:
|
||||||
|
await self.logger.warning(
|
||||||
|
f'Reply ACK error: errcode={result.get("errcode")}, errmsg={result.get("errmsg")}'
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self._pending_acks.pop(req_id, None)
|
||||||
|
await self.logger.warning(f'Reply ACK timeout ({self._reply_ack_timeout}s) for req_id={req_id}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _send_frame(self, frame: dict):
|
||||||
|
"""Send a JSON frame over the WebSocket connection."""
|
||||||
|
if self._ws and not self._ws.closed:
|
||||||
|
await self._ws.send_str(json.dumps(frame, ensure_ascii=False))
|
||||||
|
|
||||||
|
def _clear_pending_acks(self, reason: str):
|
||||||
|
"""Reject all pending ACK futures on disconnection."""
|
||||||
|
for req_id, future in self._pending_acks.items():
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(ConnectionError(reason))
|
||||||
|
self._pending_acks.clear()
|
||||||
@@ -4,6 +4,7 @@ import base64
|
|||||||
import binascii
|
import binascii
|
||||||
import httpx
|
import httpx
|
||||||
import traceback
|
import traceback
|
||||||
|
from urllib.parse import quote
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Callable, Dict, Any
|
from typing import Callable, Dict, Any
|
||||||
@@ -21,23 +22,30 @@ class WecomClient:
|
|||||||
EncodingAESKey: str,
|
EncodingAESKey: str,
|
||||||
contacts_secret: str,
|
contacts_secret: str,
|
||||||
logger: None,
|
logger: None,
|
||||||
|
unified_mode: bool = False,
|
||||||
|
api_base_url: str = 'https://qyapi.weixin.qq.com/cgi-bin',
|
||||||
):
|
):
|
||||||
self.corpid = corpid
|
self.corpid = corpid
|
||||||
self.secret = secret
|
self.secret = secret
|
||||||
self.access_token_for_contacts = ''
|
self.access_token_for_contacts = ''
|
||||||
self.token = token
|
self.token = token
|
||||||
self.aes = EncodingAESKey
|
self.aes = EncodingAESKey
|
||||||
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'
|
self.base_url = api_base_url
|
||||||
self.access_token = ''
|
self.access_token = ''
|
||||||
self.secret_for_contacts = contacts_secret
|
self.secret_for_contacts = contacts_secret
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
self.unified_mode = unified_mode
|
||||||
self.app = Quart(__name__)
|
self.app = Quart(__name__)
|
||||||
|
|
||||||
|
# 只有在非统一模式下才注册独立路由
|
||||||
|
if not self.unified_mode:
|
||||||
self.app.add_url_rule(
|
self.app.add_url_rule(
|
||||||
'/callback/command',
|
'/callback/command',
|
||||||
'handle_callback',
|
'handle_callback',
|
||||||
self.handle_callback_request,
|
self.handle_callback_request,
|
||||||
methods=['GET', 'POST'],
|
methods=['GET', 'POST'],
|
||||||
)
|
)
|
||||||
|
|
||||||
self._message_handlers = {
|
self._message_handlers = {
|
||||||
'example': [],
|
'example': [],
|
||||||
}
|
}
|
||||||
@@ -50,7 +58,7 @@ class WecomClient:
|
|||||||
return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip())
|
return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip())
|
||||||
|
|
||||||
async def get_access_token(self, secret):
|
async def get_access_token(self, secret):
|
||||||
url = f'https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={secret}'
|
url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}'
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.get(url)
|
response = await client.get(url)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -60,6 +68,31 @@ class WecomClient:
|
|||||||
await self.logger.error(f'获取accesstoken失败:{response.json()}')
|
await self.logger.error(f'获取accesstoken失败:{response.json()}')
|
||||||
raise Exception(f'未获取access token: {data}')
|
raise Exception(f'未获取access token: {data}')
|
||||||
|
|
||||||
|
async def get_user_info(self, userid: str) -> dict:
|
||||||
|
"""
|
||||||
|
Get user information by user ID using the application secret.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userid: The user ID to look up.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: User information including 'name' field.
|
||||||
|
"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
|
||||||
|
url = self.base_url + '/user/get?access_token=' + self.access_token + '&userid=' + quote(userid)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
data = response.json()
|
||||||
|
if data.get('errcode') == 40014 or data.get('errcode') == 42001:
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
return await self.get_user_info(userid)
|
||||||
|
if data.get('errcode', 0) != 0:
|
||||||
|
await self.logger.error(f'获取用户信息失败:{data}')
|
||||||
|
return {}
|
||||||
|
return data
|
||||||
|
|
||||||
async def get_users(self):
|
async def get_users(self):
|
||||||
if not self.check_access_token_for_contacts():
|
if not self.check_access_token_for_contacts():
|
||||||
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)
|
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)
|
||||||
@@ -109,14 +142,13 @@ class WecomClient:
|
|||||||
async def send_image(self, user_id: str, agent_id: int, media_id: str):
|
async def send_image(self, user_id: str, agent_id: int, media_id: str):
|
||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
self.access_token = await self.get_access_token(self.secret)
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
url = self.base_url + '/media/upload?access_token=' + self.access_token
|
|
||||||
|
url = self.base_url + '/message/send?access_token=' + self.access_token
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
params = {
|
params = {
|
||||||
'touser': user_id,
|
'touser': user_id,
|
||||||
'toparty': '',
|
|
||||||
'totag': '',
|
|
||||||
'agentid': agent_id,
|
|
||||||
'msgtype': 'image',
|
'msgtype': 'image',
|
||||||
|
'agentid': agent_id,
|
||||||
'image': {
|
'image': {
|
||||||
'media_id': media_id,
|
'media_id': media_id,
|
||||||
},
|
},
|
||||||
@@ -125,27 +157,73 @@ class WecomClient:
|
|||||||
'enable_duplicate_check': 0,
|
'enable_duplicate_check': 0,
|
||||||
'duplicate_check_interval': 1800,
|
'duplicate_check_interval': 1800,
|
||||||
}
|
}
|
||||||
try:
|
|
||||||
response = await client.post(url, json=params)
|
response = await client.post(url, json=params)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
except Exception as e:
|
|
||||||
await self.logger.error(f'发送图片失败:{data}')
|
|
||||||
raise Exception('Failed to send image: ' + str(e))
|
|
||||||
|
|
||||||
# 企业微信错误码40014和42001,代表accesstoken问题
|
|
||||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||||
self.access_token = await self.get_access_token(self.secret)
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
return await self.send_image(user_id, agent_id, media_id)
|
return await self.send_image(user_id, agent_id, media_id)
|
||||||
|
|
||||||
if data['errcode'] != 0:
|
if data['errcode'] != 0:
|
||||||
|
await self.logger.error(f'发送图片失败:{data}')
|
||||||
raise Exception('Failed to send image: ' + str(data))
|
raise Exception('Failed to send image: ' + str(data))
|
||||||
|
|
||||||
|
async def send_voice(self, user_id: str, agent_id: int, media_id: str):
|
||||||
|
if not await self.check_access_token():
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
url = self.base_url + '/message/send?access_token=' + self.access_token
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
params = {
|
||||||
|
'touser': user_id,
|
||||||
|
'msgtype': 'voice',
|
||||||
|
'agentid': agent_id,
|
||||||
|
'voice': {
|
||||||
|
'media_id': media_id,
|
||||||
|
},
|
||||||
|
'safe': 0,
|
||||||
|
'enable_id_trans': 0,
|
||||||
|
'enable_duplicate_check': 0,
|
||||||
|
'duplicate_check_interval': 1800,
|
||||||
|
}
|
||||||
|
response = await client.post(url, json=params)
|
||||||
|
data = response.json()
|
||||||
|
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
return await self.send_voice(user_id, agent_id, media_id)
|
||||||
|
if data['errcode'] != 0:
|
||||||
|
await self.logger.error(f'发送语音失败:{data}')
|
||||||
|
raise Exception('Failed to send voice: ' + str(data))
|
||||||
|
|
||||||
|
async def send_file(self, user_id: str, agent_id: int, media_id: str):
|
||||||
|
if not await self.check_access_token():
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
url = self.base_url + '/message/send?access_token=' + self.access_token
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
params = {
|
||||||
|
'touser': user_id,
|
||||||
|
'msgtype': 'file',
|
||||||
|
'agentid': agent_id,
|
||||||
|
'file': {
|
||||||
|
'media_id': media_id,
|
||||||
|
},
|
||||||
|
'safe': 0,
|
||||||
|
'enable_id_trans': 0,
|
||||||
|
'enable_duplicate_check': 0,
|
||||||
|
'duplicate_check_interval': 1800,
|
||||||
|
}
|
||||||
|
response = await client.post(url, json=params)
|
||||||
|
data = response.json()
|
||||||
|
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
return await self.send_file(user_id, agent_id, media_id)
|
||||||
|
if data['errcode'] != 0:
|
||||||
|
await self.logger.error(f'发送文件失败:{data}')
|
||||||
|
raise Exception('Failed to send file: ' + str(data))
|
||||||
|
|
||||||
async def send_private_msg(self, user_id: str, agent_id: int, content: str):
|
async def send_private_msg(self, user_id: str, agent_id: int, content: str):
|
||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
self.access_token = await self.get_access_token(self.secret)
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
|
||||||
url = self.base_url + '/message/send?access_token=' + self.access_token
|
url = self.base_url + '/message/send?access_token=' + self.access_token
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient(timeout=None) as client:
|
||||||
params = {
|
params = {
|
||||||
'touser': user_id,
|
'touser': user_id,
|
||||||
'msgtype': 'text',
|
'msgtype': 'text',
|
||||||
@@ -168,25 +246,43 @@ class WecomClient:
|
|||||||
raise Exception('Failed to send message: ' + str(data))
|
raise Exception('Failed to send message: ' + str(data))
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
|
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||||
|
return await self._handle_callback_internal(request)
|
||||||
|
|
||||||
|
async def handle_unified_webhook(self, req):
|
||||||
|
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req: Quart Request 对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
响应数据
|
||||||
"""
|
"""
|
||||||
处理回调请求,包括 GET 验证和 POST 消息接收。
|
return await self._handle_callback_internal(req)
|
||||||
|
|
||||||
|
async def _handle_callback_internal(self, req):
|
||||||
|
"""
|
||||||
|
处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req: Quart Request 对象
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
msg_signature = request.args.get('msg_signature')
|
msg_signature = req.args.get('msg_signature')
|
||||||
timestamp = request.args.get('timestamp')
|
timestamp = req.args.get('timestamp')
|
||||||
nonce = request.args.get('nonce')
|
nonce = req.args.get('nonce')
|
||||||
|
|
||||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
|
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
|
||||||
if request.method == 'GET':
|
if req.method == 'GET':
|
||||||
echostr = request.args.get('echostr')
|
echostr = req.args.get('echostr')
|
||||||
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
await self.logger.error('验证失败')
|
await self.logger.error('验证失败')
|
||||||
raise Exception(f'验证失败,错误码: {ret}')
|
raise Exception(f'验证失败,错误码: {ret}')
|
||||||
return reply_echo_str
|
return reply_echo_str
|
||||||
|
|
||||||
elif request.method == 'POST':
|
elif req.method == 'POST':
|
||||||
encrypt_msg = await request.data
|
encrypt_msg = await req.data
|
||||||
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
|
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
await self.logger.error('消息解密失败')
|
await self.logger.error('消息解密失败')
|
||||||
@@ -270,7 +366,7 @@ class WecomClient:
|
|||||||
return ext
|
return ext
|
||||||
return 'jpg' # 默认返回jpg
|
return 'jpg' # 默认返回jpg
|
||||||
|
|
||||||
async def upload_to_work(self, image: platform_message.Image):
|
async def upload_image_to_work(self, image: platform_message.Image):
|
||||||
"""
|
"""
|
||||||
获取 media_id
|
获取 media_id
|
||||||
"""
|
"""
|
||||||
@@ -287,7 +383,7 @@ class WecomClient:
|
|||||||
file_bytes = await f.read()
|
file_bytes = await f.read()
|
||||||
file_name = image.path.split('/')[-1]
|
file_name = image.path.split('/')[-1]
|
||||||
elif image.url:
|
elif image.url:
|
||||||
file_bytes = await self.download_image_to_bytes(image.url)
|
file_bytes = await self.download_media_to_bytes(image.url)
|
||||||
file_name = image.url.split('/')[-1]
|
file_name = image.url.split('/')[-1]
|
||||||
elif image.base64:
|
elif image.base64:
|
||||||
try:
|
try:
|
||||||
@@ -322,7 +418,7 @@ class WecomClient:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||||
self.access_token = await self.get_access_token(self.secret)
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
media_id = await self.upload_to_work(image)
|
media_id = await self.upload_image_to_work(image)
|
||||||
if data.get('errcode', 0) != 0:
|
if data.get('errcode', 0) != 0:
|
||||||
await self.logger.error(f'上传图片失败:{data}')
|
await self.logger.error(f'上传图片失败:{data}')
|
||||||
raise Exception('failed to upload file')
|
raise Exception('failed to upload file')
|
||||||
@@ -330,13 +426,128 @@ class WecomClient:
|
|||||||
media_id = data.get('media_id')
|
media_id = data.get('media_id')
|
||||||
return media_id
|
return media_id
|
||||||
|
|
||||||
async def download_image_to_bytes(self, url: str) -> bytes:
|
async def upload_voice_to_work(self, voice: platform_message.Voice):
|
||||||
|
"""
|
||||||
|
上传语音文件到企业微信
|
||||||
|
"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
|
||||||
|
file_bytes = None
|
||||||
|
file_name = 'voice.mp3'
|
||||||
|
|
||||||
|
if voice.path:
|
||||||
|
async with aiofiles.open(voice.path, 'rb') as f:
|
||||||
|
file_bytes = await f.read()
|
||||||
|
file_name = voice.path.split('/')[-1]
|
||||||
|
elif voice.url:
|
||||||
|
file_bytes = await self.download_media_to_bytes(voice.url)
|
||||||
|
file_name = voice.url.split('/')[-1]
|
||||||
|
elif voice.base64:
|
||||||
|
try:
|
||||||
|
base64_data = voice.base64
|
||||||
|
if ',' in base64_data:
|
||||||
|
base64_data = base64_data.split(',', 1)[1]
|
||||||
|
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
|
||||||
|
padded_base64 = base64_data + '=' * padding
|
||||||
|
file_bytes = base64.b64decode(padded_base64)
|
||||||
|
except binascii.Error as e:
|
||||||
|
raise ValueError(f'Invalid base64 string: {str(e)}')
|
||||||
|
else:
|
||||||
|
await self.logger.error('Voice对象出错')
|
||||||
|
raise ValueError('voice对象出错')
|
||||||
|
|
||||||
|
boundary = '-------------------------acebdf13572468'
|
||||||
|
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
|
||||||
|
body = (
|
||||||
|
(
|
||||||
|
f'--{boundary}\r\n'
|
||||||
|
f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n'
|
||||||
|
f'Content-Type: application/octet-stream\r\n\r\n'
|
||||||
|
).encode('utf-8')
|
||||||
|
+ file_bytes
|
||||||
|
+ f'\r\n--{boundary}--\r\n'.encode('utf-8')
|
||||||
|
)
|
||||||
|
|
||||||
|
# print(body)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(url, headers=headers, content=body)
|
||||||
|
data = response.json()
|
||||||
|
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
media_id = await self.upload_voice_to_work(voice)
|
||||||
|
if data.get('errcode', 0) != 0:
|
||||||
|
await self.logger.error(f'上传语音文件失败:{data}')
|
||||||
|
raise Exception('failed to upload file')
|
||||||
|
media_id = data.get('media_id')
|
||||||
|
return media_id
|
||||||
|
|
||||||
|
async def upload_file_to_work(self, file: platform_message.File):
|
||||||
|
"""
|
||||||
|
上传文件到企业微信
|
||||||
|
"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
|
||||||
|
file_bytes = None
|
||||||
|
file_name = 'file.txt'
|
||||||
|
if file.path:
|
||||||
|
async with aiofiles.open(file.path, 'rb') as f:
|
||||||
|
file_bytes = await f.read()
|
||||||
|
file_name = file.path.split('/')[-1]
|
||||||
|
elif file.url:
|
||||||
|
file_bytes = await self.download_media_to_bytes(file.url)
|
||||||
|
file_name = file.url.split('/')[-1]
|
||||||
|
elif file.base64:
|
||||||
|
try:
|
||||||
|
base64_data = file.base64
|
||||||
|
if ',' in base64_data:
|
||||||
|
base64_data = base64_data.split(',', 1)[1]
|
||||||
|
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
|
||||||
|
padded_base64 = base64_data + '=' * padding
|
||||||
|
file_bytes = base64.b64decode(padded_base64)
|
||||||
|
except binascii.Error as e:
|
||||||
|
raise ValueError(f'Invalid base64 string: {str(e)}')
|
||||||
|
else:
|
||||||
|
await self.logger.error('File对象出错')
|
||||||
|
raise ValueError('file对象出错')
|
||||||
|
boundary = '-------------------------acebdf13572468'
|
||||||
|
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
|
||||||
|
body = (
|
||||||
|
(
|
||||||
|
f'--{boundary}\r\n'
|
||||||
|
f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n'
|
||||||
|
f'Content-Type: application/octet-stream\r\n\r\n'
|
||||||
|
).encode('utf-8')
|
||||||
|
+ file_bytes
|
||||||
|
+ f'\r\n--{boundary}--\r\n'.encode('utf-8')
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(url, headers=headers, content=body)
|
||||||
|
data = response.json()
|
||||||
|
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
media_id = await self.upload_file_to_work(file)
|
||||||
|
if data.get('errcode', 0) != 0:
|
||||||
|
await self.logger.error(f'上传文件失败:{data}')
|
||||||
|
raise Exception('failed to upload file')
|
||||||
|
media_id = data.get('media_id')
|
||||||
|
return media_id
|
||||||
|
|
||||||
|
async def download_media_to_bytes(self, url: str) -> bytes:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.get(url)
|
response = await client.get(url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.content
|
return response.content
|
||||||
|
|
||||||
# 进行media_id的获取
|
# 进行media_id的获取
|
||||||
async def get_media_id(self, image: platform_message.Image):
|
async def get_media_id(self, media: platform_message.Image | platform_message.Voice | platform_message.File):
|
||||||
media_id = await self.upload_to_work(image=image)
|
if isinstance(media, platform_message.Image):
|
||||||
|
media_id = await self.upload_image_to_work(image=media)
|
||||||
|
elif isinstance(media, platform_message.Voice):
|
||||||
|
media_id = await self.upload_voice_to_work(voice=media)
|
||||||
|
elif isinstance(media, platform_message.File):
|
||||||
|
media_id = await self.upload_file_to_work(file=media)
|
||||||
|
else:
|
||||||
|
raise ValueError('Unsupported media type')
|
||||||
return media_id
|
return media_id
|
||||||
|
|||||||
@@ -10,22 +10,41 @@ from typing import Callable
|
|||||||
from .wecomcsevent import WecomCSEvent
|
from .wecomcsevent import WecomCSEvent
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
class WecomCSClient:
|
class WecomCSClient:
|
||||||
def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str, logger: None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
corpid: str,
|
||||||
|
secret: str,
|
||||||
|
token: str,
|
||||||
|
EncodingAESKey: str,
|
||||||
|
logger: None,
|
||||||
|
unified_mode: bool = False,
|
||||||
|
api_base_url: str = 'https://qyapi.weixin.qq.com/cgi-bin',
|
||||||
|
):
|
||||||
self.corpid = corpid
|
self.corpid = corpid
|
||||||
self.secret = secret
|
self.secret = secret
|
||||||
self.access_token_for_contacts = ''
|
self.access_token_for_contacts = ''
|
||||||
self.token = token
|
self.token = token
|
||||||
self.aes = EncodingAESKey
|
self.aes = EncodingAESKey
|
||||||
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'
|
self.base_url = api_base_url
|
||||||
self.access_token = ''
|
self.access_token = ''
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
self.unified_mode = unified_mode
|
||||||
self.app = Quart(__name__)
|
self.app = Quart(__name__)
|
||||||
|
|
||||||
|
# Customer info cache: {external_userid: (info_dict, timestamp)}
|
||||||
|
self._customer_cache: dict[str, tuple[dict, float]] = {}
|
||||||
|
self._cache_ttl = 60 # Cache TTL in seconds (1 minute)
|
||||||
|
|
||||||
|
# 只有在非统一模式下才注册独立路由
|
||||||
|
if not self.unified_mode:
|
||||||
self.app.add_url_rule(
|
self.app.add_url_rule(
|
||||||
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']
|
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']
|
||||||
)
|
)
|
||||||
|
|
||||||
self._message_handlers = {
|
self._message_handlers = {
|
||||||
'example': [],
|
'example': [],
|
||||||
}
|
}
|
||||||
@@ -61,7 +80,7 @@ class WecomCSClient:
|
|||||||
return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip())
|
return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip())
|
||||||
|
|
||||||
async def get_access_token(self, secret):
|
async def get_access_token(self, secret):
|
||||||
url = f'https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={secret}'
|
url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}'
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.get(url)
|
response = await client.get(url)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -167,7 +186,7 @@ class WecomCSClient:
|
|||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
self.access_token = await self.get_access_token(self.secret)
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
|
||||||
url = f'https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token={self.access_token}'
|
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
'touser': external_userid,
|
'touser': external_userid,
|
||||||
@@ -188,31 +207,75 @@ class WecomCSClient:
|
|||||||
return await self.send_text_msg(open_kfid, external_userid, msgid, content)
|
return await self.send_text_msg(open_kfid, external_userid, msgid, content)
|
||||||
if data['errcode'] != 0:
|
if data['errcode'] != 0:
|
||||||
await self.logger.error(f'发送消息失败:{data}')
|
await self.logger.error(f'发送消息失败:{data}')
|
||||||
raise Exception('Failed to send message')
|
raise Exception(f'Failed to send message: {data}')
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
||||||
|
if not await self.check_access_token():
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
|
||||||
|
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
|
||||||
|
payload = {
|
||||||
|
'touser': external_userid,
|
||||||
|
'open_kfid': open_kfid,
|
||||||
|
'msgid': msgid,
|
||||||
|
'msgtype': 'image',
|
||||||
|
'image': {
|
||||||
|
'media_id': media_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
|
||||||
|
if data['errcode'] != 0:
|
||||||
|
await self.logger.error(f'发送图片消息失败:{data}')
|
||||||
|
raise Exception('Failed to send image message')
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
|
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||||
|
return await self._handle_callback_internal(request)
|
||||||
|
|
||||||
|
async def handle_unified_webhook(self, req):
|
||||||
|
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req: Quart Request 对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
响应数据
|
||||||
"""
|
"""
|
||||||
处理回调请求,包括 GET 验证和 POST 消息接收。
|
return await self._handle_callback_internal(req)
|
||||||
|
|
||||||
|
async def _handle_callback_internal(self, req):
|
||||||
|
"""
|
||||||
|
处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req: Quart Request 对象
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
msg_signature = request.args.get('msg_signature')
|
msg_signature = req.args.get('msg_signature')
|
||||||
timestamp = request.args.get('timestamp')
|
timestamp = req.args.get('timestamp')
|
||||||
nonce = request.args.get('nonce')
|
nonce = req.args.get('nonce')
|
||||||
try:
|
try:
|
||||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
|
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f'初始化失败,错误码: {e}')
|
raise Exception(f'初始化失败,错误码: {e}')
|
||||||
|
|
||||||
if request.method == 'GET':
|
if req.method == 'GET':
|
||||||
echostr = request.args.get('echostr')
|
echostr = req.args.get('echostr')
|
||||||
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
raise Exception(f'验证失败,错误码: {ret}')
|
raise Exception(f'验证失败,错误码: {ret}')
|
||||||
return reply_echo_str
|
return reply_echo_str
|
||||||
|
|
||||||
elif request.method == 'POST':
|
elif req.method == 'POST':
|
||||||
encrypt_msg = await request.data
|
encrypt_msg = await req.data
|
||||||
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
|
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
raise Exception(f'消息解密失败,错误码: {ret}')
|
raise Exception(f'消息解密失败,错误码: {ret}')
|
||||||
@@ -285,7 +348,7 @@ class WecomCSClient:
|
|||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
self.access_token = await self.get_access_token(self.secret)
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
|
||||||
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
|
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=image'
|
||||||
file_bytes = None
|
file_bytes = None
|
||||||
file_name = 'uploaded_file.txt'
|
file_name = 'uploaded_file.txt'
|
||||||
|
|
||||||
@@ -331,7 +394,7 @@ class WecomCSClient:
|
|||||||
self.access_token = await self.get_access_token(self.secret)
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
media_id = await self.upload_to_work(image)
|
media_id = await self.upload_to_work(image)
|
||||||
if data.get('errcode', 0) != 0:
|
if data.get('errcode', 0) != 0:
|
||||||
raise Exception('failed to upload file')
|
raise Exception(f'failed to upload image: {data}')
|
||||||
|
|
||||||
media_id = data.get('media_id')
|
media_id = data.get('media_id')
|
||||||
return media_id
|
return media_id
|
||||||
@@ -346,3 +409,53 @@ class WecomCSClient:
|
|||||||
async def get_media_id(self, image: platform_message.Image):
|
async def get_media_id(self, image: platform_message.Image):
|
||||||
media_id = await self.upload_to_work(image=image)
|
media_id = await self.upload_to_work(image=image)
|
||||||
return media_id
|
return media_id
|
||||||
|
|
||||||
|
async def get_customer_info(self, external_userid: str) -> dict | None:
|
||||||
|
"""
|
||||||
|
Get customer information by external_userid with caching.
|
||||||
|
|
||||||
|
Uses a 1-minute cache to avoid repeated API calls for the same user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
external_userid: The external user ID of the customer.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Customer info dict with 'nickname', 'avatar', etc., or None if not found.
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
current_time = time.time()
|
||||||
|
if external_userid in self._customer_cache:
|
||||||
|
cached_info, cached_time = self._customer_cache[external_userid]
|
||||||
|
if current_time - cached_time < self._cache_ttl:
|
||||||
|
return cached_info
|
||||||
|
|
||||||
|
# Cache miss or expired, fetch from API
|
||||||
|
if not await self.check_access_token():
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
|
||||||
|
url = f'{self.base_url}/kf/customer/batchget?access_token={self.access_token}'
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'external_userid_list': [external_userid],
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get('errcode') in [40014, 42001]:
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
return await self.get_customer_info(external_userid)
|
||||||
|
|
||||||
|
if data.get('errcode', 0) != 0:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.warning(f'Failed to get customer info: {data}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
customer_list = data.get('customer_list', [])
|
||||||
|
if customer_list:
|
||||||
|
customer_info = customer_list[0]
|
||||||
|
# Store in cache
|
||||||
|
self._customer_cache[external_userid] = (customer_info, current_time)
|
||||||
|
return customer_info
|
||||||
|
return None
|
||||||
|
|||||||
4
src/langbot/libs/weknora_api/__init__.py
Normal file
4
src/langbot/libs/weknora_api/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .client import AsyncWeKnoraClient
|
||||||
|
from .errors import WeKnoraAPIError
|
||||||
|
|
||||||
|
__all__ = ['AsyncWeKnoraClient', 'WeKnoraAPIError']
|
||||||
180
src/langbot/libs/weknora_api/client.py
Normal file
180
src/langbot/libs/weknora_api/client.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import typing
|
||||||
|
import json
|
||||||
|
|
||||||
|
from .errors import WeKnoraAPIError
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncWeKnoraClient:
|
||||||
|
"""WeKnora API 客户端"""
|
||||||
|
|
||||||
|
api_key: str
|
||||||
|
base_url: str
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: str,
|
||||||
|
base_url: str = 'http://localhost:80/api/v1',
|
||||||
|
) -> None:
|
||||||
|
self.api_key = api_key
|
||||||
|
self.base_url = base_url
|
||||||
|
|
||||||
|
async def create_session(
|
||||||
|
self,
|
||||||
|
title: str = '',
|
||||||
|
description: str = '',
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> str:
|
||||||
|
"""创建会话,返回 session_id"""
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
trust_env=True,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
|
payload: dict[str, typing.Any] = {}
|
||||||
|
if title:
|
||||||
|
payload['title'] = title
|
||||||
|
if description:
|
||||||
|
payload['description'] = description
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/sessions',
|
||||||
|
headers={
|
||||||
|
'X-API-Key': self.api_key,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
raise WeKnoraAPIError(f'{response.status_code} {response.text}')
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return data['data']['id']
|
||||||
|
|
||||||
|
async def agent_chat(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
query: str,
|
||||||
|
user: str,
|
||||||
|
agent_id: str = '',
|
||||||
|
knowledge_base_ids: list[str] | None = None,
|
||||||
|
web_search_enabled: bool = False,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||||
|
"""
|
||||||
|
Agent 智能对话(SSE 流式)
|
||||||
|
|
||||||
|
响应事件类型:
|
||||||
|
- agent_query: Agent 开始处理
|
||||||
|
- thinking: 思考过程
|
||||||
|
- tool_call: 工具调用
|
||||||
|
- tool_result: 工具结果
|
||||||
|
- references: 知识库引用
|
||||||
|
- answer: 回答内容
|
||||||
|
- reflection: 反思
|
||||||
|
- session_title: 会话标题
|
||||||
|
- error: 错误
|
||||||
|
"""
|
||||||
|
if knowledge_base_ids is None:
|
||||||
|
knowledge_base_ids = []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
trust_env=True,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
|
payload: dict[str, typing.Any] = {
|
||||||
|
'query': query,
|
||||||
|
'agent_enabled': True,
|
||||||
|
'channel': 'im',
|
||||||
|
}
|
||||||
|
if agent_id:
|
||||||
|
payload['agent_id'] = agent_id
|
||||||
|
if knowledge_base_ids:
|
||||||
|
payload['knowledge_base_ids'] = knowledge_base_ids
|
||||||
|
if web_search_enabled:
|
||||||
|
payload['web_search_enabled'] = True
|
||||||
|
|
||||||
|
async with client.stream(
|
||||||
|
'POST',
|
||||||
|
f'/agent-chat/{session_id}',
|
||||||
|
headers={
|
||||||
|
'X-API-Key': self.api_key,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
) as r:
|
||||||
|
async for chunk in r.aiter_lines():
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise WeKnoraAPIError(f'{r.status_code} {chunk}')
|
||||||
|
if chunk.strip() == '':
|
||||||
|
continue
|
||||||
|
if chunk.startswith('data:'):
|
||||||
|
try:
|
||||||
|
data = json.loads(chunk[5:].strip())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
yield data
|
||||||
|
# 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
|
||||||
|
if data.get('response_type') == 'error':
|
||||||
|
return
|
||||||
|
|
||||||
|
async def knowledge_chat(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
query: str,
|
||||||
|
user: str,
|
||||||
|
agent_id: str = 'builtin-quick-answer',
|
||||||
|
knowledge_base_ids: list[str] | None = None,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||||
|
"""
|
||||||
|
知识库 RAG 问答(SSE 流式)
|
||||||
|
|
||||||
|
响应事件类型:
|
||||||
|
- references: 知识库引用
|
||||||
|
- answer: 回答内容
|
||||||
|
"""
|
||||||
|
if knowledge_base_ids is None:
|
||||||
|
knowledge_base_ids = []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
trust_env=True,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
|
payload: dict[str, typing.Any] = {
|
||||||
|
'query': query,
|
||||||
|
'channel': 'im',
|
||||||
|
}
|
||||||
|
if agent_id:
|
||||||
|
payload['agent_id'] = agent_id
|
||||||
|
if knowledge_base_ids:
|
||||||
|
payload['knowledge_base_ids'] = knowledge_base_ids
|
||||||
|
|
||||||
|
async with client.stream(
|
||||||
|
'POST',
|
||||||
|
f'/knowledge-chat/{session_id}',
|
||||||
|
headers={
|
||||||
|
'X-API-Key': self.api_key,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
) as r:
|
||||||
|
async for chunk in r.aiter_lines():
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise WeKnoraAPIError(f'{r.status_code} {chunk}')
|
||||||
|
if chunk.strip() == '':
|
||||||
|
continue
|
||||||
|
if chunk.startswith('data:'):
|
||||||
|
try:
|
||||||
|
data = json.loads(chunk[5:].strip())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
yield data
|
||||||
|
# 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
|
||||||
|
if data.get('response_type') == 'error':
|
||||||
|
return
|
||||||
6
src/langbot/libs/weknora_api/errors.py
Normal file
6
src/langbot/libs/weknora_api/errors.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class WeKnoraAPIError(Exception):
|
||||||
|
"""WeKnora API 请求失败"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = ''):
|
||||||
|
self.message = message
|
||||||
|
super().__init__(self.message)
|
||||||
22
src/langbot/pkg/api/http/controller/groups/box.py
Normal file
22
src/langbot/pkg/api/http/controller/groups/box.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .. import group
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('box', '/api/v1/box')
|
||||||
|
class BoxRouterGroup(group.RouterGroup):
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
status = await self.ap.box_service.get_status()
|
||||||
|
return self.success(data=status)
|
||||||
|
|
||||||
|
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
sessions = await self.ap.box_service.get_sessions()
|
||||||
|
return self.success(data=sessions)
|
||||||
|
|
||||||
|
@self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
errors = self.ap.box_service.get_recent_errors()
|
||||||
|
return self.success(data=errors)
|
||||||
52
src/langbot/pkg/api/http/controller/groups/extensions.py
Normal file
52
src/langbot/pkg/api/http/controller/groups/extensions.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import quart
|
||||||
|
|
||||||
|
from .. import group
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('extensions', '/api/v1/extensions')
|
||||||
|
class ExtensionsRouterGroup(group.RouterGroup):
|
||||||
|
"""Unified API for installed extensions (plugins, MCP servers, skills)."""
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _() -> quart.Response:
|
||||||
|
plugins, mcp_servers, skills = await asyncio.gather(
|
||||||
|
self.ap.plugin_connector.list_plugins(),
|
||||||
|
self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True),
|
||||||
|
self.ap.skill_service.list_skills(),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sort_key(item: dict) -> str:
|
||||||
|
if item['type'] == 'plugin':
|
||||||
|
return (
|
||||||
|
item['plugin']
|
||||||
|
.get('manifest', {})
|
||||||
|
.get('manifest', {})
|
||||||
|
.get('metadata', {})
|
||||||
|
.get('name', '')
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
|
if item['type'] == 'mcp':
|
||||||
|
return (item['server'].get('name') or '').lower()
|
||||||
|
if item['type'] == 'skill':
|
||||||
|
return (item['skill'].get('display_name') or item['skill'].get('name') or '').lower()
|
||||||
|
return ''
|
||||||
|
|
||||||
|
extensions: list[dict] = []
|
||||||
|
if isinstance(plugins, list):
|
||||||
|
for plugin in plugins:
|
||||||
|
extensions.append({'type': 'plugin', 'plugin': plugin})
|
||||||
|
if isinstance(mcp_servers, list):
|
||||||
|
for server in mcp_servers:
|
||||||
|
extensions.append({'type': 'mcp', 'server': server})
|
||||||
|
if isinstance(skills, list):
|
||||||
|
for skill in skills:
|
||||||
|
extensions.append({'type': 'skill', 'skill': skill})
|
||||||
|
|
||||||
|
extensions.sort(key=_sort_key)
|
||||||
|
|
||||||
|
return self.success(data={'extensions': extensions})
|
||||||
@@ -13,9 +13,9 @@ from .. import group
|
|||||||
@group.group_class('files', '/api/v1/files')
|
@group.group_class('files', '/api/v1/files')
|
||||||
class FilesRouterGroup(group.RouterGroup):
|
class FilesRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@self.route('/image/<image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
async def _(image_key: str) -> quart.Response:
|
async def _(image_key: str) -> quart.Response:
|
||||||
if '/' in image_key or '\\' in image_key:
|
if '..' in image_key or '\\' in image_key:
|
||||||
return quart.Response(status=404)
|
return quart.Response(status=404)
|
||||||
|
|
||||||
if not await self.ap.storage_mgr.storage_provider.exists(image_key):
|
if not await self.ap.storage_mgr.storage_provider.exists(image_key):
|
||||||
@@ -28,8 +28,56 @@ class FilesRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return quart.Response(image_bytes, mimetype=mime_type)
|
return quart.Response(image_bytes, mimetype=mime_type)
|
||||||
|
|
||||||
@self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/images', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def _() -> quart.Response:
|
async def upload_image() -> quart.Response:
|
||||||
|
request = quart.request
|
||||||
|
|
||||||
|
# Check file size limit before reading the file
|
||||||
|
content_length = request.content_length
|
||||||
|
if content_length and content_length > group.MAX_FILE_SIZE:
|
||||||
|
return self.fail(400, 'Image size exceeds 10MB limit.')
|
||||||
|
|
||||||
|
# get file bytes from 'file'
|
||||||
|
files = await request.files
|
||||||
|
if 'file' not in files:
|
||||||
|
return self.fail(400, 'No image file provided')
|
||||||
|
|
||||||
|
file = files['file']
|
||||||
|
assert isinstance(file, quart.datastructures.FileStorage)
|
||||||
|
|
||||||
|
file_bytes = await asyncio.to_thread(file.stream.read)
|
||||||
|
|
||||||
|
# Double-check actual file size after reading
|
||||||
|
if len(file_bytes) > group.MAX_FILE_SIZE:
|
||||||
|
return self.fail(400, 'Image size exceeds 10MB limit.')
|
||||||
|
|
||||||
|
# Validate image file extension
|
||||||
|
allowed_extensions = {'jpg', 'jpeg', 'png', 'gif', 'webp'}
|
||||||
|
if '.' in file.filename:
|
||||||
|
file_name, extension = file.filename.rsplit('.', 1)
|
||||||
|
extension = extension.lower()
|
||||||
|
else:
|
||||||
|
return self.fail(400, 'Invalid image file: no file extension')
|
||||||
|
|
||||||
|
if extension not in allowed_extensions:
|
||||||
|
return self.fail(400, f'Invalid image format. Allowed formats: {", ".join(allowed_extensions)}')
|
||||||
|
|
||||||
|
# check if file name contains '/' or '\'
|
||||||
|
if '/' in file_name or '\\' in file_name:
|
||||||
|
return self.fail(400, 'File name contains invalid characters')
|
||||||
|
|
||||||
|
file_key = file_name + '_' + str(uuid.uuid4())[:8] + '.' + extension
|
||||||
|
|
||||||
|
# save file to storage
|
||||||
|
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'file_key': file_key,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def upload_document() -> quart.Response:
|
||||||
request = quart.request
|
request = quart.request
|
||||||
|
|
||||||
# Check file size limit before reading the file
|
# Check file size limit before reading the file
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from ... import group
|
|||||||
@group.group_class('knowledge_base', '/api/v1/knowledge/bases')
|
@group.group_class('knowledge_base', '/api/v1/knowledge/bases')
|
||||||
class KnowledgeBaseRouterGroup(group.RouterGroup):
|
class KnowledgeBaseRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@self.route('', methods=['POST', 'GET'])
|
@self.route('', methods=['POST', 'GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def handle_knowledge_bases() -> quart.Response:
|
async def handle_knowledge_bases() -> quart.Response:
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
knowledge_bases = await self.ap.knowledge_service.get_knowledge_bases()
|
knowledge_bases = await self.ap.knowledge_service.get_knowledge_bases()
|
||||||
@@ -13,7 +13,10 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
elif quart.request.method == 'POST':
|
elif quart.request.method == 'POST':
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
|
try:
|
||||||
knowledge_base_uuid = await self.ap.knowledge_service.create_knowledge_base(json_data)
|
knowledge_base_uuid = await self.ap.knowledge_service.create_knowledge_base(json_data)
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(400, -1, str(e))
|
||||||
return self.success(data={'uuid': knowledge_base_uuid})
|
return self.success(data={'uuid': knowledge_base_uuid})
|
||||||
|
|
||||||
return self.http_status(405, -1, 'Method not allowed')
|
return self.http_status(405, -1, 'Method not allowed')
|
||||||
@@ -21,6 +24,7 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
|
|||||||
@self.route(
|
@self.route(
|
||||||
'/<knowledge_base_uuid>',
|
'/<knowledge_base_uuid>',
|
||||||
methods=['GET', 'DELETE', 'PUT'],
|
methods=['GET', 'DELETE', 'PUT'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
)
|
)
|
||||||
async def handle_specific_knowledge_base(knowledge_base_uuid: str) -> quart.Response:
|
async def handle_specific_knowledge_base(knowledge_base_uuid: str) -> quart.Response:
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
@@ -38,7 +42,7 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
|
|||||||
elif quart.request.method == 'PUT':
|
elif quart.request.method == 'PUT':
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
await self.ap.knowledge_service.update_knowledge_base(knowledge_base_uuid, json_data)
|
await self.ap.knowledge_service.update_knowledge_base(knowledge_base_uuid, json_data)
|
||||||
return self.success({})
|
return self.success(data={'uuid': knowledge_base_uuid})
|
||||||
|
|
||||||
elif quart.request.method == 'DELETE':
|
elif quart.request.method == 'DELETE':
|
||||||
await self.ap.knowledge_service.delete_knowledge_base(knowledge_base_uuid)
|
await self.ap.knowledge_service.delete_knowledge_base(knowledge_base_uuid)
|
||||||
@@ -47,6 +51,7 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
|
|||||||
@self.route(
|
@self.route(
|
||||||
'/<knowledge_base_uuid>/files',
|
'/<knowledge_base_uuid>/files',
|
||||||
methods=['GET', 'POST'],
|
methods=['GET', 'POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
)
|
)
|
||||||
async def get_knowledge_base_files(knowledge_base_uuid: str) -> str:
|
async def get_knowledge_base_files(knowledge_base_uuid: str) -> str:
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
@@ -63,8 +68,12 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
|
|||||||
if not file_id:
|
if not file_id:
|
||||||
return self.http_status(400, -1, 'File ID is required')
|
return self.http_status(400, -1, 'File ID is required')
|
||||||
|
|
||||||
|
parser_plugin_id = json_data.get('parser_plugin_id')
|
||||||
|
|
||||||
# 调用服务层方法将文件与知识库关联
|
# 调用服务层方法将文件与知识库关联
|
||||||
task_id = await self.ap.knowledge_service.store_file(knowledge_base_uuid, file_id)
|
task_id = await self.ap.knowledge_service.store_file(
|
||||||
|
knowledge_base_uuid, file_id, parser_plugin_id=parser_plugin_id
|
||||||
|
)
|
||||||
return self.success(
|
return self.success(
|
||||||
{
|
{
|
||||||
'task_id': task_id,
|
'task_id': task_id,
|
||||||
@@ -74,6 +83,7 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
|
|||||||
@self.route(
|
@self.route(
|
||||||
'/<knowledge_base_uuid>/files/<file_id>',
|
'/<knowledge_base_uuid>/files/<file_id>',
|
||||||
methods=['DELETE'],
|
methods=['DELETE'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
)
|
)
|
||||||
async def delete_specific_file_in_kb(file_id: str, knowledge_base_uuid: str) -> str:
|
async def delete_specific_file_in_kb(file_id: str, knowledge_base_uuid: str) -> str:
|
||||||
await self.ap.knowledge_service.delete_file(knowledge_base_uuid, file_id)
|
await self.ap.knowledge_service.delete_file(knowledge_base_uuid, file_id)
|
||||||
@@ -82,9 +92,18 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
|
|||||||
@self.route(
|
@self.route(
|
||||||
'/<knowledge_base_uuid>/retrieve',
|
'/<knowledge_base_uuid>/retrieve',
|
||||||
methods=['POST'],
|
methods=['POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
)
|
)
|
||||||
async def retrieve_knowledge_base(knowledge_base_uuid: str) -> str:
|
async def retrieve_knowledge_base(knowledge_base_uuid: str) -> str:
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
query = json_data.get('query')
|
query = json_data.get('query')
|
||||||
results = await self.ap.knowledge_service.retrieve_knowledge_base(knowledge_base_uuid, query)
|
|
||||||
|
if not query or not query.strip():
|
||||||
|
return self.http_status(400, -1, 'Query is required and cannot be empty')
|
||||||
|
|
||||||
|
# Extract retrieval_settings to allow dynamic control over Knowledge Engine behavior (e.g. top_k, filters)
|
||||||
|
retrieval_settings = json_data.get('retrieval_settings', {})
|
||||||
|
results = await self.ap.knowledge_service.retrieve_knowledge_base(
|
||||||
|
knowledge_base_uuid, query, retrieval_settings
|
||||||
|
)
|
||||||
return self.success(data={'results': results})
|
return self.success(data={'results': results})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user