RKQVCqn-wE)}&ZQ}e3ei69V)c?GdAHaZ|Gt#q
z|L&*}4wJV6AuMw$8}#2v80IK@I-o%n6mx+?F%!{$fEdNA=Y`TEJ`1`YLG?b!zxyg3HtB%
z6@}lkL>x2?YD5L-sYigFJ`Z$dO?HZ-2lUwN+0P^lv&2^v{(ubz{s&a)4TNLP7h>^m{R_Cj~;$
zWEvX`{Fdg}lQL?EVL{3&yC%IZ&nYrRogZbBK+rWm8L!SyBwlz`h;`neh2rNJIVTXt
z&t1m^13#fC%KST;^K}iKN=FX450NWSTt>%CY=!37GYHEp5@MY{mJ>J9)~v#%|dhRDTFQDCB!!CQm)cT7gi6
zOyO?Vdi;KF&1RxXi!Yn{u3=liM6|Jf?;SMx7^1tbNKe?1&S>1aNC}AWaW5+
zqRAYtF!YugaZtemFf)z@T~#xD#372VZxot^&}1%GSnwJ}li6?;a?
zKxlfbp$AzGqNK4J^haVhQN
z&N2ERRay?ZdJ-r)XOE1VMrzm%rKQnrGcrbI8vzZ1+J*z8Bj2{tZyN%dJt`;sxb5tq3J0`o+G2ifK-jk
zxm|EY6tw*cMOJ`#hx$s_>xu%1A|Oj$Hxe3Pn1260h3BEf+Mq#kwD&Y0$OPKnhE2wJlS$qp=-PC>;?foiQkhIMA6D
zL+YGD{oqvJBZ;=i0=GosjzB1?SoIu+#GwwU#!my4g&`H{kVk26fgEk?Iy0W=blar7
zL4A*?@0CPbbO98_ao6d%K+FuQsM3+3t0!j7DwQIMP}*A|w;k@`isr$S%mnZ-fNwa_w-W*>;KHG&X^uTk*WH8$J$52U
z=}6lR>x&{wked%dX+Ef}KzU>bve?gaUkacIz=Z&84P&-lAY3@~Jk7DE>bjYxK~I_u
z;==J)bqFOj>;?o!Jhz7dlEbUMs1x=-0$6Tm-?j>b_WN$rEe_(2IOO)$u3$fJB`Rwf
zKp;pCvpxp!2fO;VO&}Ca&tZf9AL+fHsKY7;#i58Ir26ep4(EQLU%oSlKvaGX;C%oa
z?daPUfoRLQg`wB=CRWfUsnf$4B!O-$5~x_(eh|sE);m2(DVdd
zosSyhm^h?^b&$5M_r#H7$b#;@r?~)rXm$UV3WWA?*U&!hTs`+{;Uf<0kSm=X5We^q
zG8rMaHbdO<8PE~6y)O^z
zkyP)Y82V5P0(cpKWoc|I69`)nd|GebD!OKpmcHLkq=wy)c5eFejD<(eAsh1^DT)DD
zmT$`vfzW>6k9B8O4}@$z&!I>X#GRWVAE>jf4-aS1IYCdysQ|ubg$)@Cgrdn@Ht4@Y
zcZcQn)q&LZHg;^#qHaBM8(ETj#$TD?ZUBZ(s-`1jfv}j*D{4Efe?l{neqEfg&A9wWLcYYc;d)Q4D6ok<$3_e
zn|aWbK-f^=K{L-mJ9lx&?4#W%q5x_825(rEr%2WdMCC3s4;dB+;n?Mb(HCo#mW~Ld
z3_{EmN$T1F^d{tai5A*!Pa^I`0KR7QA;SV;gZ}?E`b=Nsx@}P6=7vVn-fiAeZXP4X
zZ9PG2IGE9pKxox_7%fYX4oQNzXNz_=!wBN;EqRg2++$!Yo}l+-9)OdLJYYy5OfYb}
zVUq?T7J<0+b1gOJP;@QjZiI3;^ZAQM_M(ZySa6~l^aa!=(Y-!Q`+e6Fnq*$EzbBnB
zh?_qJRa6R6T&7irqWnRvkiVABvooeuPV#b64G+YGBMCDW0Js3ab9!#pE07Qy@ZFl3
zT?3=E9fs6)*skV1asWk^t=c`;!#$P?gs|+H6d{H~a4FR*1cIOf1zOm-Ix$zppmciY
zGdy}DIwuYlbtH6F^6bc<=n@DM;Pi(Mvm8i8HAu>7whUWSO8|)yso1CgH)KMv=fCBK~I{cC5!VI-bkX@mA%AS
zR1Z=!)J;PQgz@{X*VP?OgBd^FQ0Zk7E(SeumhSsK@)HN=9(}oXs2c_s2t^gqKJH>Y
zJ<+w(Kyf|;wj~V+g+VHZ6tYJiBPCoD$mIaI!EP8-AheIWhN7rJ8&J`fX)`!RtIk=#
z4X@hf9$iR+E5oZ4#Q?rN*bRdUgbnzv(bG$&9)O}Kkkayu+VIFtBzr?k^e|}n$$$bO
zEORbJ$k6#fDPl=ZT`J^y#a|DHQ9Xyvp(T2#O>Q2hWk7+@K2BHUBNYgmHLQ0#?2scl
zk32(+XHBVzD+c;OdVx^X2%2Lrw9_Jw;fk+$f(#K
zT;`F7I24$u7j=ApQo0|c6#>mMXXzX!G4>350OWDP~%h8+K`Vs*EBv
zAAr()z)Y7OIY<^Q?WSW0fl$#ujyOCU7I>R-J|hrmPHu}M2?E=O7iyiGoQ2;!!-kPn$3mU-kJg7arS
z%+PBD)hjlguuMjYep4vXHb~pnL*o2e2npp6biE#nYCrSl8^aGpQ6MF9LLf_e9>?t~
z5KHU3fsaE`FH0Ns$VIe$kKA}8Eb949_YjLBWFjG?+7**L@*XiahF2w)E8fR5SpInnmHbMO_r}*rK4L5Jv<1luL!4yhXI7nc&>R5
z^qhxu3xsBvBV9AcBQH^8C70$NJ?UPo(gZ>gGTT*iJn|B4Xq7!7?MCncs6e*|A*8}>
zi#+lRZPz?|4n&z@G*SYwJSnxuut&(wh+QMhLH7`gCUe}j#v^~wB6hRyK$KA`(Jc^)
zGM@3x!(#yQz9X40?DF
zFN3s4#)$ERR)siE(9VUV<{+l(rF(evB+E|=8f79hfl$=Y=|w#}hCt}dEAP<_Aq+ju
zZ6iE#8(ESec9^SspXF3|Lo1Jr5u$lBG4IP1Paqx{AtrdpV%8;rvkX-rUXef#Qz)_w
zA(odzt8NG(q+d2U4-Y+PJLb6=t*aXXiYj&62#?$&k9N!lMHPttva}u^$rL_$v|~Of
zgaRnHjqu1V)S*>A?s=qxC{TvSztqDLBtguE!kdo4Kta3PMtGP*Qe^B*v_nv2C{VDM
zk0K@u$)73M!$%O8zg&V96$J;y4zx?g=MFdAaSdi2IbHj@*WQ4lKxvKvP0`+o;r6!y
zt;rQGNrtkk84x5ul!2X1+H1K0Bg@hjw~g>fLIF%oIY1LIqep=9v!E+VKteuHJ{E^r
z39mf=Dqei&9dtVE_@8^?#cqN0wwVG|RsfXssdPN8_bKjl<9MaL6Hvva3a7qR6PQxp
z0`L;B_i&e(>f9}?q%KO++Lfb$k?i`3MdO9oqlv`6wdEA<)ss9gY9|`E>{s8m}=w
zbM8$1=JES+(|2#ijx9TEus%KORlToY?yqMUwZ}*M5GX3ZX&hzyTqKy_z68u$yl;^aq+YYZ`93`<9Hv
zk)oim0QW!kYy9_(Kf$UGS7qGZP_#6+V(Z53m^@>m?S1K{)z(m#?TjMJyY1{DSBXjj
zw#HkrBhiX4;w{*gXwz1UGUdiQZbTp$u*61{U?_;&esL?VxbQoU%t=M7e$2nD6O81ncLP-;r>
z-p2KK^qDto?K4~$#<#EemTm2ydiJS!@tMD2^XHpw>qDjSU^5Oh9707^|AVONuu*-p
z6GChMrzirJ?aBv|dL7+@uqKGSiQkBbB`x(sbH_;!qQva!BYTi5!jPwpggmnz
zezx(g*KF_qysw>$!s0?(`?&6wYjNe*uClccm8~0hVEn`h+MD0!3uwYn|L+Eg9kx|S
z)~eBVc@Rn7LKxeGqljs7xCPX;u~F8KZ#Wf&YMech%c&&$9FTRT#FM$&2AbWT1wY7cSNHU~@16NbdkS26{5K&rhS1)#=P=GR_4D#f1
z$WupZ^9iHu+-mHv8`qh$PuNXf=sa8iX{r3exelz=)77eK_Gk#@VpZdv+C++Wa^!&oIu0y!iuuNBeUFRUAsEZS$;uroQO@
z%gLg+n$Vmp0#Vfd7mS5;;zTHw_U;u72dc+ayKRE)#NOX%XY>69qDazO0GHa?YhP4v
zAugE!`KU2KFn=pB#2u~^UNXY?YF@6^$
z{dTnTmA$+6V$MA0Y836?=iEUmH{k1kadcpv{DpRY*(-1qFP87WLG&v
z&ez{_)r_w}acQxZ`)T94P1=%XCLB6&D5K|%(s-cJ_C7eDdsX|QZ~n{9=C`Y#X{m2|
z%^qbz{D9`zvu*3z&)U|sS;kEqi(o+zi%(vJ6Hh-8^N*X~@1D);zrdrveFSS(t~J$1
z-R?RIEY(k2OPlR|OqtH0PjLOP!U|D11=s>`aF=NM=>hmv8JZtJXqr{rp(YmK=4ie^qIAY{vTs-v@oLoH*
zVT)E_YA4oO(vBHo|FAV|a7}6lJw?A@8d(rux6=apQ~N{92ZLofl$KVQeqJ$|&3wh1
z?;+Z5f6#Q@$LW_pr<9JxrPEKt!pa$7N&1b;AKdl>Jo?v1F=5JtLHn36dAudI8bupk
zZL@WSuHV6+egctr!PcX7RFns`#yydgl#DbTWY>7G(R3(zEZTuL{$l^ZtR3$R{t0{J
z$jLZw!XnMP^hBLsos&EgeDw`i58B3zIWsJ=)hKPxd}wzUFZ6vOod4;M8V3yN}%@IUjGq;MUlLi3&){oQvCL}@lhn<$FlXYs>VLQ7>
zi;=_teR<-6C-B)PpBa8G$oO&2gyT_LR0)X}@vkRWn6_asI$NUH_`LqqKOD_!!>zAQ
zT7nrfr(<;W=4^uQG~C|^e-XYmE3A(rTK|`ongES5Dp-ndp4&Sa
z+{zULXDU2qvGW3vI}(We&ED|+zN=R$(t8kIc;0Sn=rwgf#Q8M(jg9+ti6qhuc8S25)>}HTHaH{*>SN1z#q#X*$$|
zH7nPoy|1Xa2*;mvyy-5_$O($WdG4*~Ec7Ncu5{qSQ-*_zul7C);Ha#Y>>y*aGPS
z=VFvp;_(>j>vy8Cumqt{VRu>@BzK_KtzCzeD?Y}Gw^o=w^HA(7(B>TUAd3O(p8gPR
z_02f?ilwIdNU`SOcd%=P`CL`SHRUKCSKj^oyRQ#Cx?X(BV$=O(Lfg~t+ug;psVpXa
z8E{Z8#rY@g6o?A_(QZgfCk>i$#10fiMq696cJ<_Me|`loJeApADFtBJxCMiXBgO9b
zH$zN_IO>}xf*m5WdtZc39;+UB6Z=0Mc)p;A+7o(j`Zaty@HW^nOODB?FEd15U2A9N
zk`x1OQVsZ5lmz|-MNw?EjFMz9uiBgF%m|B?9-DC+$5qWniC_PjmikrO@a8Q~YhuyM
zp-uI8`?f!4EDn|Osb5ac+xNGiswsf-K>v8v@=@i^&p!kaEswr!cP|IiE$^lncv(k5
zyBOymV*|cx?6ORSUbPp}akZv0l2{2vQK+dMkCT?2gm+(i*Hjx*O2%S#`QV#u>F8*V
z;**Eo#)jusV$A%BC>mRa;_9+q_e2}p(X#Up>Q-&lwUnzDp`&XasClH{y`lXcmenrA
zb6ej-hxp~V{~K3*!?Hg0()`eycBb`7@w>r(NF9FUEwujcJL!K&NR7Xw(DuAIa#;w%
zZKv4X&42`hVU(1VYbi0+N$MqMU26EOdYBF3-={7GL);1CGYcE!P`hM0>Oa}hOL+B>
zM!dAMYh!Nw_ROYqFyS4BQUmbl*LxM&*Cw2u;_&2ape3X9PPLLq5=x}x@EL3qRxlZJr#U?-^U_8f*Ggw}>rs^=g&hC)lvI9W>s
zJrqI4hjE4Gql2@VNvF-h$O+CSsXqL>S0T6ATNEeBGkR{)B@i*rKdUH;BjX?TskK0d
zU}>A`Bz*Mf+O)%|!MOEjKSn4#cyQy?(i&~$C*LTl9)Xz`IJ>MH`)D(wuh>3VL2}+P
z;L>hS^u>KEO0(FRxHnU)^gtc%1oMB^L~YVEqfNloOOMy+OZljB-1VEg22CA0v&@~d
z5q1(R3*(sYp8>}2Tq-7h&mlD4{%5DU`w0MDhjVlvj1c4TI~7^BeNkG4-gp4ygUk*#
zhC)TCsv4(xk4%_-FK$cwv$5e(C}7q<Q|`$n>xnnhDlyTsYkg7FP|&~E=p!4wSu9yjuUv9qTN
zB4Rvomr<+CLiV+Rd3ZG-4lR;Us1Tz^kB84^U*hK5etsJ+x~yvxktGbu0!6m<q$(UH;VQ1BMT0i=BxF6;+M=GjX$t
z=S;)Az6I3A^to6^jHu8y{hZ|Vb5F-jx8LY!pLRePf}^fD8RJiIJ_$q9JuldO2&ykX
zNEQ+>gsJo%Vm$FjNf7NhTs;8nM#X9ts>>?Xi-w
zDOEW2u8UE-*x4j-az_FOZh9Q?f7zOTtuN03c+t#*ri;u8(b#fDQQFNuZxkvafqUp<
zkX3uZaegf;qS3@}Rx@g5HJ1JS5^ZvuOU(Gne4KdeR}rps_K1Nhj{VpC5uG17S_HHe
zz_-mlY+4|Stn3jx;zI^|Sq=O2rzomR*goIT4WYEj?fk1x)k4R!nb620EV<=;Oh4b*
z`G$&|5OCl}kAdIX;B?nF05F}#Dx)%`VmxuDBFkGdX~PVWdtZUD%ih}NMyWY=3eNb|
zWthCo*~nKtFn(4G8#&{DzJZa`%%x7vl%~6%L&DywwCPw2Afsh6`lfTgf%s?Uk8aA6
zw3G31U(dt{!-x{%D>j0uDF-t;d+Hd{9D|BkV^O_e5@bn6^k5rg(O8-xEAbZxHNh^p
z`ZQF`sp&c}?=adf`E5_3!-iBxI`}F8(`h^lmd(ptAc`z+qi9k|82TvFjhI1{5&vK_
z$cQl53E34*>njYP>WErQS#|^pMiwD*pap!yxfPqj(WRL4^+lL}Ws%xD
zaNwGU-0&PKcLI1KlLl0QSWq{sHM}#qE{sfF$G+m3tbsK(fBMw~q5F`uF&l_MJfd8_h
zA#3UrD@q$5iC*d}3frE*C>_!Ndmo@E6nxd4w3vDrIc>Cd&HLtwXx!3(LmTVRylp=Y
z?>y9i2uM4wy%VN)FnuR0BMd3KkQE}v4RT`A(NF?6=X5NnEnj|}Wx0wW6GFASn!
zRFUC4T29ga;>S4r``4V@A@9p&Ne^NPmR7hIJL0#iLoLG4bFFMwFNw7~aroA!QGE0H
zU~6;djJUrzq+KPoxg8zcc;~Z-zW%A3COiz_PgXXV(K2ru5_~jvu_7yuoEu4pbg&JF
z|KC&Sc-_`Ys2)8K54EA;%HO*!jurqvw7OBN1p-BqH}lchSMAQqIH(PA0qqaGiRN3M
zhPco9G7G~6InJZ?sTHWd+a<-ChAJq5SiJ5E&4us_bL09>NcZF!j6?U|31YtuR
zns0av9d8(~>)|mt!u}TQ|BpXv8Ax25Z=_ge<-DUOwg?1WB@~_!;|XWe@uflYCVzMX
zhj0Cp6G@PV1*+HBclocuZ`tRzDM+3#|EFDz+a?fn?T`FGmSo%O_UeJ(uoq3&J&Mi`
z?9aWGuOznYL;dpmy1Yir`PwdNP=^LTv$F$BSFzF%<=dm@bA_ST2u)A1xvwEnB5}0+
z^6!YfxEi4|=OJ*yESo-21H)?7?IaL)xvzTgiipR1i;oLT6PNriY)I+v_%*A
zi^CsLgt%9Mz6autgJ`?|Rm5Idi^9uK0ylH4+a_ftT9(6y-qggAWvHIx6uqf?GQoC%
zXedfsqCI+nuPD5VqA2S#DwsqSiaIypW>%x%YsY&+Ng}m(p#9m8(Ei*%oj&!ahwT7P
zuw%t49nJ_Owfq~2UF<6?_`6em>V@A>rwN6gS&hQwC%`w!*>s!Pg6c8ao?3zS=RSgx
zur|A;MaTdycS;;^QXuGB{(U*o9zB~2hn~(BPogLaqy!H!o`AAqJ)$etgPUB1f-~l!
zVCh`YMaI&V<{};c+>FR8YtXrRTeb=V$-Rz$2jEzaAUG`$q(ssdUF0h&_`q$E2%T|=
zoe5PXy9{Pnd~BOPU!Ope%C=nURsUlJD)>tv3)3?DXKUY*|pa(
z7>)}B+Hgx+=jL!rjPnpX<4|-YrR#WagLcs)!U!#$i-ObU=IjtmY-;cF7B7DS{)?>G
z!_b4A^c=2i7q}u2xG>yOhf?Y=DxJsBgVY>B`!gSC7hM{Le{L-T3nsxgtr}dG21bg9
zT9MdXkNCRX=v=i0!d`nyOW4RR0N(`go?8aECJ=NFw<5>-3Jad22(e|KMD-GyFyg!^
zVWdMk96{{e&$Ua16on8tay)#~tKpkA2L3sY#+I^x>Ol$#{h?@3!{kK!0`Q^$ZJT^SVE>N3z}=?{2GEl~)28oLFa
z*s?Dh0t4+v^&Cq8IF@s5FtS1*=z3%Ch_|#K%Y}o#p*ij{L(eLTI$YC+QL>P=%aj`G
zr+nh`FOwK#k}x9=xf{S;PVA8DC2ItdB+5rRE~h!+T`nB@J%B+oL~HYl1aErc?U6M^
z0GtmXeZhVg$u@y#NWAc5qCMK^D=hdEfPTquWwFa!xIK*Wump7yerhuKFzd+LSXNmO
zUyrx6&zA&2+bfYpNt;{5TO&xcMLluk2TuZ+l`GwtA2hiY#yA+hgZYgj}V_@*mu`
z$HP)C2k@BNMhr#PdXSzdvitym;{o(v7V^k7z5sA!E{FrUB9J8i1TY7{gKit*VVZjZ
z91S36QqJU(K#<%xx(dLV0JgbpjE8PEBo8=UpG`}+ddM|_q<9s;WB|7&mk2$wlh$N?
zj#&WScH0;;rKfgm{n`~`p-0JnQWafBuSKT7`2
z?*kBX+aN2+H-V&R2XF^~aR7dqJeB5QE%D?Wa6Eu}0AxibIjhN6fuv|lX8f)Ma2ibDW?4PZKea{;{Rwm~__O8^!ESdiST9Tvma;UbWpsDJ3w
z0Mr6_AX&1@BR%Sz?*8OZRsCRD<+ediGaLnyVmE-R0gMFj?cw;xAB1<4rOZYHxOOonQ6x0XPf5
z8OZ}ZW85~*Vs str:
+ """Convert LangBot MessageChain to Satori message format"""
+ content_parts = []
+
+ for component in message_chain:
+ if isinstance(component, platform_message.Plain):
+ text = component.text.replace("&", "&").replace("<", "<").replace(">", ">")
+ content_parts.append(text)
+ elif isinstance(component, platform_message.Image):
+ # Prefer URL over base64 to avoid buffer overflow issues with large images
+ if component.url:
+ content_parts.append(f' ')
+ elif hasattr(component, "base64") and component.base64:
+ # Process base64 data
+ base64_data = component.base64
+ # Remove whitespace that might corrupt the data
+ base64_data = base64_data.replace('\n', '').replace('\r', '').replace(' ', '')
+
+ # Check size - if too large, try to upload
+ MAX_INLINE_SIZE = 32 * 1024 # 32KB limit for inline base64
+
+ # Extract raw base64 and mime type
+ raw_b64 = base64_data
+ mime_type = "image/png"
+ if base64_data.startswith("data:"):
+ try:
+ header, raw_b64 = base64_data.split(',', 1)
+ if ';' in header:
+ mime_type = header.split(':')[1].split(';')[0]
+ except (ValueError, IndexError):
+ pass
+
+ if len(raw_b64) > MAX_INLINE_SIZE:
+ # Try to upload large image
+ try:
+ # Fix base64 padding if needed
+ padding = 4 - len(raw_b64) % 4
+ if padding != 4:
+ raw_b64 += '=' * padding
+ image_bytes = base64.b64decode(raw_b64)
+ uploaded_url = await adapter.upload_image(image_bytes, mime_type)
+ if uploaded_url:
+ await adapter.logger.info(f"Satori 图片上传成功: {len(image_bytes)} 字节")
+ content_parts.append(f' ')
+ else:
+ # Upload failed, use inline (may fail)
+ await adapter.logger.warning("Satori 图片上传失败,使用内联模式")
+ content_parts.append(f' ')
+ except Exception as e:
+ await adapter.logger.error(f"Satori 图片处理失败: {e}")
+ content_parts.append(f' ')
+ else:
+ # Small image, use inline
+ content_parts.append(f' ')
+ elif isinstance(component, platform_message.At):
+ if component.target:
+ content_parts.append(f' ')
+ elif isinstance(component, platform_message.AtAll):
+ content_parts.append(' ')
+ elif isinstance(component, platform_message.Reply):
+ content_parts.append(f' ')
+ elif isinstance(component, platform_message.Quote):
+ content_parts.append(f'
')
+ elif isinstance(component, platform_message.Face):
+ # Satori中的表情可以使用emoticon元素
+ face_id = getattr(component, 'face_id', 'unknown')
+ content_parts.append(f' ')
+ elif isinstance(component, platform_message.Voice):
+ if hasattr(component, 'url') and component.url:
+ content_parts.append(f' ')
+ elif isinstance(component, platform_message.File):
+ if hasattr(component, 'url') and component.url:
+ content_parts.append(f' ')
+
+ return "".join(content_parts)
+
+ @staticmethod
+ async def target2yiri(
+ message_data: dict, adapter: "SatoriAdapter", bot_account_id: str = ""
+ ) -> platform_message.MessageChain:
+ """Convert Satori message to LangBot MessageChain
+
+ Parses Satori's XML-like message format and converts to LangBot MessageChain.
+ Handles text, images, mentions, replies, quotes, emoticons, audio, and files.
+ """
+ content = message_data.get("content", "")
+
+ components = []
+
+ if content:
+ # HTML实体解码 - 注意顺序:先解码 & 再解码其他实体
+ # 这样可以正确处理 < -> < -> <
+ content = content.replace("&", "&").replace("<", "<").replace(">", ">")
+
+ # 定义各种消息组件的正则模式 - 支持更灵活的属性顺序
+ # 使用 (?:...) 非捕获组来支持可选属性
+ patterns = [
+ # 图片 - 支持 src 在任意位置
+ (r' ]*src=["\']([^"\']+)["\'][^>]*/?\s*>', "image"),
+ # @提及用户 - id 属性
+ (r']*id=["\']([^"\']+)["\'][^>]*/?\s*>', "mention"),
+ # @全体 - type="all"
+ (r']*type=["\']all["\'][^>]*/?\s*>', "mention_all"),
+ # 回复
+ (r']*id=["\']([^"\']+)["\'][^>]*/?\s*>', "reply"),
+ # 引用
+ (r']*id=["\']([^"\']+)["\'][^>]*/?\s*>', "quote"),
+ # 表情
+ (r']*id=["\']([^"\']+)["\'][^>]*/?\s*>', "emoticon"),
+ (r']*id=["\']([^"\']+)["\'][^>]*/?\s*>', "face"),
+ # 音频
+ (r']*src=["\']([^"\']+)["\'][^>]*/?\s*>', "audio"),
+ (r']*(?:src|url)=["\']([^"\']+)["\'][^>]*/?\s*>', "audio"),
+ # 视频
+ (r']*src=["\']([^"\']+)["\'][^>]*/?\s*>', "video"),
+ # 文件 - 支持 url 或 src 属性
+ (r']*(?:url|src)=["\']([^"\']+)["\'][^>]*/?\s*>', "file"),
+ ]
+
+ # 构建联合正则表达式
+ combined_pattern = '|'.join([f'({p[0]})' for p in patterns])
+
+ # 分割消息内容,按顺序处理各种组件
+ pos = 0
+ for match in re.finditer(combined_pattern, content, re.IGNORECASE):
+ # 添加匹配前的纯文本
+ if pos < match.start():
+ text = content[pos:match.start()]
+ # 保留文本(包括空白),但跳过完全空的文本
+ if text:
+ components.append(platform_message.Plain(text=text))
+
+ # 处理匹配到的组件
+ match_text = match.group(0)
+ matched = False
+ for pattern, msg_type in patterns:
+ sub_match = re.search(pattern, match_text, re.IGNORECASE)
+ if sub_match:
+ matched = True
+ if msg_type == "image":
+ img_url = sub_match.group(1)
+ components.append(platform_message.Image(url=img_url))
+ elif msg_type == "mention":
+ target_id = sub_match.group(1)
+ components.append(platform_message.At(target=str(target_id)))
+ elif msg_type == "mention_all":
+ components.append(platform_message.AtAll())
+ elif msg_type == "reply":
+ reply_id = sub_match.group(1)
+ components.append(platform_message.Reply(id=str(reply_id)))
+ elif msg_type == "quote":
+ quote_id = sub_match.group(1)
+ # Quote requires origin field - use empty list as placeholder
+ components.append(platform_message.Quote(message_id=str(quote_id), origin=[]))
+ elif msg_type == "emoticon" or msg_type == "face":
+ emoticon_id = sub_match.group(1)
+ components.append(platform_message.Face(face_id=str(emoticon_id), face_name=f"emoticon_{emoticon_id}"))
+ elif msg_type == "audio":
+ audio_url = sub_match.group(1)
+ components.append(platform_message.Voice(url=audio_url))
+ elif msg_type == "video":
+ # 视频作为文件处理
+ video_url = sub_match.group(1)
+ components.append(platform_message.File(url=video_url, name="video"))
+ elif msg_type == "file":
+ file_url = sub_match.group(1)
+ # 尝试从标签中提取文件名
+ name_match = re.search(r'name=["\']([^"\']*)["\']', match_text, re.IGNORECASE)
+ file_name = name_match.group(1) if name_match else ""
+ components.append(platform_message.File(url=file_url, name=file_name))
+ break
+
+ # 如果没有匹配到任何已知模式,将其作为纯文本
+ if not matched:
+ components.append(platform_message.Plain(text=match_text))
+
+ pos = match.end()
+
+ # 添加剩余的文本
+ if pos < len(content):
+ remaining_text = content[pos:]
+ # 保留文本(包括空白),但跳过完全空的文本
+ if remaining_text:
+ components.append(platform_message.Plain(text=remaining_text))
+
+ # 如果没有解析出任何组件,但内容不为空,则作为纯文本
+ if not components and content:
+ components.append(platform_message.Plain(text=content))
+
+ message_chain = platform_message.MessageChain(components)
+ await adapter.logger.info(f"Satori 消息解析完成: 共 {len(components)} 个组件 内容长度={len(content)} 字符")
+ return message_chain
+
+
+class SatoriEventConverter(abstract_platform_adapter.AbstractEventConverter):
+ """Convert between Satori events and LangBot events"""
+
+ @staticmethod
+ def _ensure_string(value: typing.Any, default: str = "") -> str:
+ """Ensure value is string type"""
+ if value is None:
+ return default
+ if isinstance(value, str):
+ return value
+ return str(value)
+
+ @staticmethod
+ async def target2yiri(
+ event_data: dict, adapter: "SatoriAdapter", bot_account_id: str = ""
+ ) -> typing.Optional[platform_events.MessageEvent]:
+ """Convert Satori event to LangBot event
+
+ This method is used for standalone event conversion.
+ Note: The adapter's convert_satori_message method is preferred for better handling.
+ """
+ event_type = event_data.get("type", "")
+
+ if event_type == "message-created":
+ message = event_data.get("message", {})
+ user = event_data.get("user", {})
+ guild = event_data.get("guild")
+ channel = event_data.get("channel", {})
+ login = event_data.get("login", {})
+
+ user_name = SatoriEventConverter._ensure_string(
+ user.get("name") or user.get("nick"), ""
+ )
+ user_id = SatoriEventConverter._ensure_string(user.get("id"), "")
+ message_id = SatoriEventConverter._ensure_string(message.get("id"), "")
+ message_content = SatoriEventConverter._ensure_string(
+ message.get("content"), ""
+ )
+
+ # Log received message
+ await adapter.logger.info(f"Satori EventConverter 消息接收: 用户ID={user_id}, 用户名={user_name}, 内容长度={len(message_content)}")
+
+ # Convert message content to MessageChain
+ message_chain = await SatoriMessageConverter.target2yiri(
+ {"content": message_content}, adapter, bot_account_id
+ )
+
+ # Insert Source component at the beginning of the message chain
+ message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.datetime.now()))
+
+ # Build original event object for source_platform_object
+ original_event = {
+ "type": event_type,
+ "message": message,
+ "user": user,
+ "channel": channel,
+ "guild": guild,
+ "login": login,
+ }
+
+ # Try to get timestamp from message or use current time
+ msg_timestamp = message.get("timestamp") or message.get("created_at")
+ if msg_timestamp:
+ try:
+ if isinstance(msg_timestamp, (int, float)):
+ event_time = int(msg_timestamp) if msg_timestamp > 1e12 else int(msg_timestamp * 1000)
+ event_time = event_time // 1000 if event_time > 1e12 else event_time
+ else:
+ # Try parsing ISO format
+ event_time = int(datetime.datetime.fromisoformat(str(msg_timestamp).replace('Z', '+00:00')).timestamp())
+ except (ValueError, TypeError):
+ event_time = int(time.time())
+ else:
+ event_time = int(time.time())
+
+ # Determine message type based on channel.type or guild presence
+ # In Satori protocol:
+ # - channel.type = 0: TEXT channel (group/guild message)
+ # - channel.type = 1: DIRECT channel (private message)
+ channel_type = channel.get("type")
+ channel_id = SatoriEventConverter._ensure_string(channel.get("id"), "")
+
+ # Check if it's a private/direct message
+ is_private = (channel_type == 1)
+
+ # Check if it's a group message
+ is_group = (guild and guild.get("id")) or (channel_type == 0)
+
+ if is_private:
+ # Private/friend message
+ sender = platform_entities.Friend(
+ id=user_id,
+ nickname=user_name,
+ remark=user_name,
+ )
+ friend_message = platform_events.FriendMessage(
+ message_chain=message_chain,
+ sender=sender,
+ time=event_time,
+ source_platform_object=original_event,
+ )
+ await adapter.logger.info(f"Satori 私聊消息已构建: 用户ID={user_id}, 用户名={user_name}")
+ return friend_message
+ elif is_group:
+ # Group message
+ # Use guild.id if available, otherwise use channel.id as group_id
+ group_id = SatoriEventConverter._ensure_string(guild.get("id"), "") if guild and guild.get("id") else channel_id
+ group_name = guild.get("name", "Unknown Group") if guild else "Unknown Group"
+
+ group = platform_entities.Group(
+ id=group_id,
+ name=group_name,
+ permission=platform_entities.Permission.Member
+ )
+ sender = platform_entities.GroupMember(
+ id=user_id,
+ member_name=user_name,
+ permission=platform_entities.Permission.Member,
+ group=group,
+ special_title='',
+ )
+ group_message = platform_events.GroupMessage(
+ message_chain=message_chain,
+ sender=sender,
+ time=event_time,
+ source_platform_object=original_event,
+ )
+ await adapter.logger.info(f"Satori 群消息已构建: 群ID={group_id}, 发送者={user_name}")
+ return group_message
+ else:
+ # Fallback: treat as private message if cannot determine type
+ sender = platform_entities.Friend(
+ id=user_id,
+ nickname=user_name,
+ remark=user_name,
+ )
+ friend_message = platform_events.FriendMessage(
+ message_chain=message_chain,
+ sender=sender,
+ time=event_time,
+ source_platform_object=original_event,
+ )
+ await adapter.logger.info(f"Satori 私聊消息已构建 (fallback): 用户ID={user_id}, 用户名={user_name}")
+ return friend_message
+ return None
+
+
+class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
+ """Satori protocol adapter for LangBot - Native implementation"""
+
+ ws: typing.Optional[typing.Any] = pydantic.Field(exclude=True, default=None)
+ session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(
+ exclude=True, default=None
+ )
+ running: bool = pydantic.Field(exclude=True, default=False)
+ sequence: int = pydantic.Field(exclude=True, default=0)
+ logins: typing.List[dict] = pydantic.Field(exclude=True, default_factory=list)
+ ready_received: bool = pydantic.Field(exclude=True, default=False)
+ heartbeat_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
+ listeners: typing.Dict[typing.Type, typing.Callable] = pydantic.Field(
+ exclude=True, default_factory=dict
+ )
+
+ message_converter: SatoriMessageConverter = pydantic.Field(
+ default_factory=SatoriMessageConverter
+ )
+ event_converter: SatoriEventConverter = pydantic.Field(
+ default_factory=SatoriEventConverter
+ )
+
+ platform: str = pydantic.Field(exclude=True, default="llonebot")
+ host: str = pydantic.Field(exclude=True, default="127.0.0.1")
+ api_base_url: str = pydantic.Field(exclude=True, default="")
+ token: str = pydantic.Field(exclude=True, default="")
+ endpoint: str = pydantic.Field(exclude=True, default="")
+ port: int = pydantic.Field(exclude=True, default=5600)
+ auto_reconnect: bool = pydantic.Field(exclude=True, default=True)
+ heartbeat_interval: int = pydantic.Field(exclude=True, default=10)
+ reconnect_delay: int = pydantic.Field(exclude=True, default=5)
+
+ def __init__(
+ self,
+ config: dict,
+ logger: abstract_platform_logger.AbstractEventLogger,
+ ):
+ """Initialize Satori adapter"""
+ host = config.get("host", "127.0.0.1")
+ port = config.get("port", 5600)
+
+ # 初始化基类
+ super().__init__(
+ config=config,
+ logger=logger,
+ platform=config.get("platform", "llonebot"),
+ host=host,
+ api_base_url=config.get(
+ "satori_api_base_url",
+ f"http://{host}:{port}/v1"
+ ),
+ token=config.get("token", ""),
+ endpoint=config.get(
+ "satori_endpoint",
+ f"ws://{host}:{port}/v1/events"
+ ),
+ auto_reconnect=True,
+ port=port,
+ heartbeat_interval=10,
+ reconnect_delay=5,
+ )
+
+ def _is_websocket_closed(self, ws) -> bool:
+ """Check if WebSocket connection is closed"""
+ if not ws:
+ return True
+ try:
+ if hasattr(ws, "closed"):
+ return ws.closed
+ if hasattr(ws, "close_code"):
+ return ws.close_code is not None
+ return False
+ except AttributeError:
+ return True
+
+ async def run(self):
+ """Start the adapter"""
+ self.running = True
+ self.session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30))
+
+ retry_count = 0
+ max_retries = 10
+
+ await self.logger.info(f"Satori 适配器启动中 - 连接到 {self.endpoint}")
+
+ while self.running:
+ try:
+ await self.connect_websocket()
+ retry_count = 0
+ except websockets.exceptions.ConnectionClosed as e:
+ await self.logger.warning(f"Satori WebSocket 连接关闭: {e}")
+ retry_count += 1
+ except Exception as e:
+ await self.logger.error(f"Satori WebSocket 连接失败: {e}")
+ retry_count += 1
+
+ if not self.running:
+ break
+
+ if retry_count >= max_retries:
+ await self.logger.error(f"达到最大重试次数 ({max_retries}),停止重试")
+ break
+
+ if not self.auto_reconnect:
+ break
+
+ delay = min(self.reconnect_delay * (2 ** (retry_count - 1)), 60)
+ await self.logger.info(f"{delay}秒后重新连接...")
+ await asyncio.sleep(delay)
+
+ if self.session:
+ await self.session.close()
+
+ async def connect_websocket(self):
+ """Connect to WebSocket"""
+ await self.logger.info(f"Satori 正在连接到 WebSocket: {self.endpoint}")
+ await self.logger.info(f"Satori HTTP API 地址: {self.api_base_url}")
+
+ if not self.endpoint.startswith(("ws://", "wss://")):
+ raise ValueError(f"WebSocket URL必须以ws://或wss://开头: {self.endpoint}")
+
+ try:
+ self.ws = await websockets.connect(self.endpoint)
+ await asyncio.sleep(0.1)
+
+ await self.send_identify()
+
+ self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
+
+ async for message in self.ws:
+ try:
+ await self.handle_message(message)
+ except Exception as e:
+ await self.logger.error(f"Satori 处理消息异常: {e}")
+
+ except websockets.exceptions.ConnectionClosed as e:
+ await self.logger.warning(f"Satori WebSocket 连接关闭: {e}")
+ raise
+ except Exception as e:
+ await self.logger.error(f"Satori WebSocket 连接异常: {e}")
+ raise
+ finally:
+ if self.heartbeat_task:
+ self.heartbeat_task.cancel()
+ try:
+ await self.heartbeat_task
+ except asyncio.CancelledError:
+ pass
+ if self.ws:
+ try:
+ await self.ws.close()
+ except Exception as e:
+ await self.logger.error(f"Satori WebSocket 关闭异常: {e}")
+
+ async def send_identify(self):
+ """Send IDENTIFY signal"""
+ if not self.ws:
+ raise Exception("WebSocket连接未建立")
+
+ if self._is_websocket_closed(self.ws):
+ raise Exception("WebSocket连接已关闭")
+
+ identify_payload = {
+ "op": 3, # IDENTIFY
+ "body": {
+ "token": str(self.token) if self.token else "",
+ },
+ }
+
+ if self.sequence > 0:
+ identify_payload["body"]["sn"] = self.sequence
+
+ try:
+ message_str = json.dumps(identify_payload, ensure_ascii=False)
+ await self.ws.send(message_str)
+ await self.logger.info("Satori IDENTIFY 信令已发送")
+ except Exception as e:
+ await self.logger.error(f"发送 IDENTIFY 信令失败: {e}")
+ raise
+
+ async def heartbeat_loop(self):
+ """Heartbeat loop"""
+ try:
+ while self.running and self.ws:
+ await asyncio.sleep(self.heartbeat_interval)
+
+ if self.ws and not self._is_websocket_closed(self.ws):
+ try:
+ ping_payload = {
+ "op": 1, # PING
+ "body": {},
+ }
+ await self.ws.send(json.dumps(ping_payload, ensure_ascii=False))
+ except Exception as e:
+ await self.logger.error(f"Satori WebSocket 发送心跳失败: {e}")
+ break
+ else:
+ break
+ except asyncio.CancelledError:
+ pass
+ except Exception as e:
+ await self.logger.error(f"心跳任务异常: {e}")
+
+ async def handle_message(self, message: str):
+ """Handle WebSocket message"""
+ try:
+ data = json.loads(message)
+ op = data.get("op")
+ body = data.get("body", {})
+
+ if op == 4: # READY
+ self.logins = body.get("logins", [])
+ self.ready_received = True
+
+ if self.logins:
+ for i, login in enumerate(self.logins):
+ platform = login.get("platform", "")
+ user = login.get("user", {})
+ user_id = user.get("id", "")
+ user_name = user.get("name", "")
+ await self.logger.info(
+ f"Satori 连接成功 - Bot {i + 1}: platform={platform}, user_id={user_id}, user_name={user_name}"
+ )
+
+ if "sn" in body:
+ self.sequence = body["sn"]
+
+ elif op == 2: # PONG
+ pass
+
+ elif op == 0: # EVENT
+ await self.handle_event(body)
+ if "sn" in body:
+ self.sequence = body["sn"]
+
+ elif op == 5: # META
+ if "sn" in body:
+ self.sequence = body["sn"]
+
+ except json.JSONDecodeError as e:
+ await self.logger.error(f"解析 WebSocket 消息失败: {e}, 消息内容: {message}")
+ except Exception as e:
+ await self.logger.error(f"处理 WebSocket 消息异常: {e}")
+
+ async def handle_event(self, event_data: dict):
+ """Handle event"""
+ try:
+ event_type = event_data.get("type")
+
+ if event_type == "message-created":
+ message = event_data.get("message", {})
+ user = event_data.get("user", {})
+ channel = event_data.get("channel", {})
+ guild = event_data.get("guild")
+ login = event_data.get("login", {})
+
+ # Skip messages from self
+ bot_user_id = login.get("user", {}).get("id")
+ msg_user_id = user.get("id")
+ if bot_user_id and msg_user_id and str(bot_user_id) == str(msg_user_id):
+ return
+
+ lb_event = await self.convert_satori_message(
+ message, user, channel, guild, login
+ )
+ if lb_event and type(lb_event) in self.listeners:
+ await self.listeners[type(lb_event)](lb_event, self)
+
+ except Exception as e:
+ await self.logger.error(f"处理事件失败: {e}\n{traceback.format_exc()}")
+
+ async def convert_satori_message(
+ self,
+ message: dict,
+ user: dict,
+ channel: dict,
+ guild: typing.Optional[dict],
+ login: dict,
+ ) -> typing.Optional[platform_events.MessageEvent]:
+ """Convert Satori message to LangBot event
+
+ This is the main method for converting Satori messages to LangBot events.
+ It handles both private and group messages based on channel.type and guild info.
+ """
+ try:
+ # Extract basic info with type safety
+ user_id = str(user.get("id", "") or "")
+ user_name = str(user.get("name", "") or user.get("nick", "") or "")
+ message_id = str(message.get("id", "") or "")
+ message_content = str(message.get("content", "") or "")
+
+ # Log received message (truncate long content)
+ log_content = message_content[:100] + "..." if len(message_content) > 100 else message_content
+ await self.logger.info(f"Satori 消息接收: 用户ID={user_id}, 用户名={user_name}, 内容长度={len(message_content)}, 预览='{log_content}'")
+
+ # Convert message content
+ message_chain = await SatoriMessageConverter.target2yiri(
+ {"content": message_content}, self, ""
+ )
+
+ # Insert Source component at the beginning of the message chain
+ message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.datetime.now()))
+
+ # Build original event object for source_platform_object
+ original_event = {
+ "type": "message-created",
+ "message": message,
+ "user": user,
+ "channel": channel,
+ "guild": guild,
+ "login": login,
+ }
+
+ # Try to get timestamp from message or use current time
+ msg_timestamp = message.get("timestamp") or message.get("created_at")
+ if msg_timestamp:
+ try:
+ if isinstance(msg_timestamp, (int, float)):
+ # Handle milliseconds vs seconds
+ event_time = int(msg_timestamp) if msg_timestamp < 1e12 else int(msg_timestamp / 1000)
+ else:
+ # Try parsing ISO format
+ event_time = int(datetime.datetime.fromisoformat(str(msg_timestamp).replace('Z', '+00:00')).timestamp())
+ except (ValueError, TypeError):
+ event_time = int(time.time())
+ else:
+ event_time = int(time.time())
+
+ # Determine message type based on channel.type or guild presence
+ # In Satori protocol:
+ # - channel.type = 0: TEXT channel (group/guild message)
+ # - channel.type = 1: DIRECT channel (private message)
+ # Some implementations (like LLOneBot) may not provide guild info for group chats
+ channel_type = channel.get("type")
+ channel_id = str(channel.get("id", "") or "")
+
+ # Check if it's a private/direct message
+ # Private message: channel.type == 1, or no guild and no channel type (legacy)
+ is_private = (channel_type == 1)
+
+ # Check if it's a group message
+ # Group message: has guild info, or channel.type == 0
+ is_group = (guild and guild.get("id")) or (channel_type == 0)
+
+ await self.logger.info(f"Satori 消息类型判断: channel_type={channel_type}, channel_id={channel_id}, is_private={is_private}, is_group={is_group}, has_guild={guild is not None}")
+
+ if is_private:
+ # Private/friend message
+ sender = platform_entities.Friend(
+ id=user_id,
+ nickname=user_name,
+ remark=user_name,
+ )
+ friend_message = platform_events.FriendMessage(
+ message_chain=message_chain,
+ sender=sender,
+ time=event_time,
+ source_platform_object=original_event,
+ )
+ await self.logger.info(f"Satori 私聊消息已构建: 用户ID={user_id}, 用户名={user_name}, 组件数={len(message_chain)}")
+ return friend_message
+ elif is_group:
+ # Group message
+ # Use guild.id if available, otherwise use channel.id as group_id
+ group_id = str(guild.get("id", "") or "") if guild and guild.get("id") else channel_id
+ group_name = str(guild.get("name", "Unknown Group") if guild else "Unknown Group")
+
+ group = platform_entities.Group(
+ id=group_id,
+ name=group_name,
+ permission=platform_entities.Permission.Member
+ )
+ sender = platform_entities.GroupMember(
+ id=user_id,
+ member_name=user_name,
+ permission=platform_entities.Permission.Member,
+ group=group,
+ special_title='',
+ )
+ group_message = platform_events.GroupMessage(
+ message_chain=message_chain,
+ sender=sender,
+ time=event_time,
+ source_platform_object=original_event,
+ )
+ await self.logger.info(f"Satori 群消息已构建: 群ID={group_id}, 发送者={user_name}, 组件数={len(message_chain)}")
+ return group_message
+ else:
+ # Fallback: treat as private message if cannot determine type
+ await self.logger.warning(f"Satori 无法确定消息类型,使用私聊作为fallback: channel_type={channel_type}")
+ sender = platform_entities.Friend(
+ id=user_id,
+ nickname=user_name,
+ remark=user_name,
+ )
+ friend_message = platform_events.FriendMessage(
+ message_chain=message_chain,
+ sender=sender,
+ time=event_time,
+ source_platform_object=original_event,
+ )
+ await self.logger.info(f"Satori 私聊消息已构建 (fallback): 用户ID={user_id}, 用户名={user_name}")
+ return friend_message
+
+ except Exception as e:
+ await self.logger.error(f"转换 Satori 消息失败: {e}\n{traceback.format_exc()}")
+ return None
+
+
+
+ async def send_http_request(
+ self,
+ method: str,
+ path: str,
+ data: typing.Optional[dict] = None,
+ platform: typing.Optional[str] = None,
+ user_id: typing.Optional[str] = None,
+ ) -> typing.Optional[dict]:
+ """Send HTTP request to Satori API"""
+ if not self.session:
+ await self.logger.error("HTTP session 未初始化")
+ return None
+
+ url = f"{self.api_base_url}{path}"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {self.token}",
+ }
+
+ if platform:
+ headers["Satori-Platform"] = platform
+ if user_id:
+ headers["Satori-User-ID"] = user_id
+
+ try:
+ async with self.session.request(
+ method, url, headers=headers, json=data
+ ) as response:
+ if response.status == 200:
+ return await response.json()
+ else:
+ text = await response.text()
+ await self.logger.error(f"Satori API 请求失败: {response.status} - {text}")
+ return None
+ except Exception as e:
+ await self.logger.error(f"Satori API 请求异常: {e}")
+ return None
+
+ async def upload_image(
+ self,
+ image_bytes: bytes,
+ mime_type: str = "image/png",
+ ) -> typing.Optional[str]:
+ """Upload image to Satori server and return the URL
+
+ Uses multipart/form-data to upload the image file via upload.create API.
+ Returns the URL of the uploaded image, or None if upload fails.
+ """
+ if not self.session:
+ await self.logger.error("HTTP session 未初始化")
+ return None
+
+ url = f"{self.api_base_url}/upload.create"
+ headers = {}
+
+ if self.token:
+ headers["Authorization"] = f"Bearer {self.token}"
+
+ platform = ""
+ user_id = ""
+ if self.logins:
+ current_login = self.logins[0]
+ platform = current_login.get("platform", "")
+ user = current_login.get("user", {})
+ user_id = user.get("id", "")
+
+ if platform:
+ headers["Satori-Platform"] = platform
+ if user_id:
+ headers["Satori-User-ID"] = user_id
+
+ try:
+ # Determine file extension from mime type
+ ext = "png"
+ if "jpeg" in mime_type or "jpg" in mime_type:
+ ext = "jpg"
+ elif "gif" in mime_type:
+ ext = "gif"
+ elif "webp" in mime_type:
+ ext = "webp"
+
+ # Create multipart form data
+ form_data = aiohttp.FormData()
+ form_data.add_field(
+ 'file',
+ image_bytes,
+ filename=f'image.{ext}',
+ content_type=mime_type
+ )
+
+ async with self.session.post(
+ url, headers=headers, data=form_data
+ ) as response:
+ if response.status == 200:
+ result = await response.json()
+ # The response should contain the URL of the uploaded file
+ if isinstance(result, dict) and 'url' in result:
+ return result['url']
+ elif isinstance(result, list) and len(result) > 0 and 'url' in result[0]:
+ return result[0]['url']
+ else:
+ await self.logger.warning(f"Satori 图片上传响应格式未知: {result}")
+ return None
+ else:
+ text = await response.text()
+ await self.logger.error(f"Satori 图片上传失败: {response.status} - {text}")
+ return None
+ except Exception as e:
+ await self.logger.error(f"Satori 图片上传异常: {e}")
+ return None
+
+ async def kill(self) -> bool:
+ """Stop the adapter"""
+ self.running = False
+ if self.heartbeat_task:
+ self.heartbeat_task.cancel()
+ if self.ws:
+ try:
+ await self.ws.close()
+ except Exception:
+ pass
+ if self.session:
+ await self.session.close()
+ await self.logger.info("Satori 适配器已停止")
+ return True
+
+ async def send_message(
+ self,
+ target_type: str,
+ target_id: str,
+ message: platform_message.MessageChain,
+ ):
+ """Send message"""
+ try:
+ content = await self.message_converter.yiri2target(message, self)
+
+ platform = ""
+ user_id = ""
+ if self.logins:
+ current_login = self.logins[0]
+ platform = current_login.get("platform", "")
+ user = current_login.get("user", {})
+ user_id = user.get("id", "")
+
+ data = {"channel_id": target_id, "content": content}
+ await self.send_http_request(
+ "POST", "/message.create", data, platform, user_id
+ )
+
+ except Exception as e:
+ await self.logger.error(f"Satori 发送消息失败: {e}")
+
+ async def reply_message(
+ self,
+ message_source: platform_events.MessageEvent,
+ message: platform_message.MessageChain,
+ quote_origin: bool = False,
+ ):
+ """Reply to message"""
+ try:
+ content = await self.message_converter.yiri2target(message, self)
+
+ # Try to get channel_id from source_platform_object first (Satori protocol needs original channel.id)
+ channel_id = ""
+ if hasattr(message_source, 'source_platform_object') and message_source.source_platform_object:
+ source_obj = message_source.source_platform_object
+ if isinstance(source_obj, dict):
+ channel = source_obj.get("channel", {})
+ if channel and channel.get("id"):
+ channel_id = str(channel.get("id"))
+
+ # Fallback: get channel_id from message source
+ if not channel_id:
+ if isinstance(message_source, platform_events.GroupMessage):
+ # Group message: use group ID
+ if hasattr(message_source.sender, "group") and hasattr(message_source.sender.group, "id"):
+ channel_id = message_source.sender.group.id
+ elif isinstance(message_source, platform_events.FriendMessage):
+ # Private message: use sender ID as channel_id
+ if hasattr(message_source.sender, "id"):
+ channel_id = message_source.sender.id
+
+ # Last fallback
+ if not channel_id:
+ if hasattr(message_source, "sender") and hasattr(message_source.sender, "id"):
+ channel_id = message_source.sender.id
+
+ if not channel_id:
+ await self.logger.error("无法获取频道ID")
+ return
+
+ platform = ""
+ user_id = ""
+ if self.logins:
+ current_login = self.logins[0]
+ platform = current_login.get("platform", "")
+ user = current_login.get("user", {})
+ user_id = user.get("id", "")
+
+ data = {"channel_id": channel_id, "content": content}
+ await self.send_http_request(
+ "POST", "/message.create", data, platform, user_id
+ )
+
+ except Exception as e:
+ await self.logger.error(f"Satori 回复消息失败: {e}")
+
+ async def is_muted(self, group_id: int) -> bool:
+ """Check if the bot is muted in a group"""
+ return False
+
+ def register_listener(
+ self,
+ event_type: typing.Type[platform_events.Event],
+ callback: typing.Callable[
+ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
+ ],
+ ):
+ """Register an event listener"""
+ self.listeners[event_type] = callback
+
+ def unregister_listener(
+ self,
+ event_type: typing.Type[platform_events.Event],
+ callback: typing.Callable[
+ [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
+ ],
+ ):
+ """Unregister an event listener"""
+ if event_type in self.listeners:
+ del self.listeners[event_type]
+
+ async def run_async(self):
+ """Async run wrapper"""
+ await self.run()
diff --git a/src/langbot/pkg/platform/sources/satori.yaml b/src/langbot/pkg/platform/sources/satori.yaml
new file mode 100644
index 00000000..325bbd22
--- /dev/null
+++ b/src/langbot/pkg/platform/sources/satori.yaml
@@ -0,0 +1,65 @@
+apiVersion: v1
+kind: MessagePlatformAdapter
+metadata:
+ name: satori
+ label:
+ en_US: Satori
+ zh_Hans: Satori
+ description:
+ en_US: SatoriAdapter
+ zh_Hans: 古明地觉协议适配器
+ icon: satori.png
+spec:
+ config:
+ - name: platform
+ label:
+ en_US: Platform
+ zh_Hans: 平台名称
+ type: string
+ required: true
+ default: "llonebot"
+ description:
+ en_US: The platform name (e.g., llonebot, discord, telegram)
+ zh_Hans: 平台名称(如 llonebot, discord, telegram)
+ - name: host
+ label:
+ en_US: Host
+ zh_Hans: 主机地址
+ type: string
+ required: true
+ default: "127.0.0.1"
+ description:
+ en_US: The host address of LLOneBot Satori server (e.g., 127.0.0.1, localhost, 192.168.1.100)
+ zh_Hans: LLOneBot Satori服务器的主机地址(如 127.0.0.1, localhost, 192.168.1.100)
+ - name: port
+ label:
+ en_US: Port
+ zh_Hans: 监听端口
+ type: int
+ required: true
+ default: 5600
+ - name: satori_api_base_url
+ label:
+ en_US: Satori API Endpoint
+ zh_Hans: Satori API 终结点
+ type: string
+ required: true
+ default: "http://localhost:5600/v1"
+ - name: satori_endpoint
+ label:
+ en_US: Satori WebSocket Endpoint
+ zh_Hans: Satori WebSocket 终结点
+ type: string
+ required: true
+ default: "ws://localhost:5600/v1/events"
+ - name: token
+ label:
+ en_US: Token
+ zh_Hans: 令牌
+ type: string
+ required: true
+ default: ""
+execution:
+ python:
+ path: ./satori.py
+ attr: SatoriAdapter
\ No newline at end of file
From 6a687ebeeb977fd89f8832582230976607ea6c5b Mon Sep 17 00:00:00 2001
From: Typer_Body
Date: Thu, 12 Feb 2026 02:48:31 +0800
Subject: [PATCH 08/37] Update README.md
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index f27c1d7d..12d805bc 100644
--- a/README.md
+++ b/README.md
@@ -115,6 +115,7 @@ docker compose up -d
| 飞书 | ✅ | |
| 钉钉 | ✅ | |
| KOOK | ✅ | |
+| Satori | ✅ | |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
From 28492a62bb913d495bd7863c37bddf6eb522801c Mon Sep 17 00:00:00 2001
From: Typer_Body
Date: Thu, 12 Feb 2026 02:48:58 +0800
Subject: [PATCH 09/37] Update README_EN.md
---
README_EN.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README_EN.md b/README_EN.md
index baa5dd84..fca39239 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -115,6 +115,7 @@ Or visit the demo environment: https://demo.langbot.dev/
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
+| Satori | ✅ | |
### LLMs
From c388339bd5db14f86980d8cb1422c6e954daf373 Mon Sep 17 00:00:00 2001
From: Typer_Body
Date: Thu, 12 Feb 2026 02:49:21 +0800
Subject: [PATCH 10/37] Update README_TW.md
---
README_TW.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/README_TW.md b/README_TW.md
index bddaf1e9..4da6878b 100644
--- a/README_TW.md
+++ b/README_TW.md
@@ -111,6 +111,7 @@ docker compose up -d
| 企微對外客服 | ✅ | |
| 企微智能機器人 | ✅ | |
| 微信公眾號 | ✅ | |
+| Satori | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
@@ -161,4 +162,4 @@ docker compose up -d
-
\ No newline at end of file
+
From ffd242392061c145d4111d543bc98c96bbe60e6c Mon Sep 17 00:00:00 2001
From: Typer_Body
Date: Thu, 12 Feb 2026 02:50:42 +0800
Subject: [PATCH 11/37] Add Satori to communication tools list
---
README_JP.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README_JP.md b/README_JP.md
index 027e19b1..43b40959 100644
--- a/README_JP.md
+++ b/README_JP.md
@@ -114,6 +114,7 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
+| Satori | ✅ | |
### LLMs
From c581c8e809bdc24a28e00f4a9f6547e1bd394e27 Mon Sep 17 00:00:00 2001
From: Typer_Body
Date: Thu, 12 Feb 2026 02:50:59 +0800
Subject: [PATCH 12/37] Add Satori to supported platforms list
---
README_ES.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README_ES.md b/README_ES.md
index 4b4b297b..b323547e 100644
--- a/README_ES.md
+++ b/README_ES.md
@@ -115,6 +115,7 @@ O visite el entorno de demostración: https://demo.langbot.dev/
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
+| Satori | ✅ | |
### LLMs
From 6a5a7182dbb0cea7ead2910ce52966f656de43de Mon Sep 17 00:00:00 2001
From: Typer_Body
Date: Thu, 12 Feb 2026 02:51:15 +0800
Subject: [PATCH 13/37] Add Satori to the supported LLMs list
---
README_FR.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README_FR.md b/README_FR.md
index f90a3646..22b59cb9 100644
--- a/README_FR.md
+++ b/README_FR.md
@@ -114,6 +114,7 @@ Ou visitez l'environnement de démonstration : https://demo.langbot.dev/
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
+| Satori | ✅ | |
### LLMs
From 24e90a7f9b9794168d0769d562e2ce3e21e960b7 Mon Sep 17 00:00:00 2001
From: Typer_Body
Date: Thu, 12 Feb 2026 02:51:37 +0800
Subject: [PATCH 14/37] Add Satori to the supported platforms list
---
README_KO.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README_KO.md b/README_KO.md
index c423cbfd..1ad9cd40 100644
--- a/README_KO.md
+++ b/README_KO.md
@@ -111,6 +111,7 @@ LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [
| WeComCS | ✅ | |
| WeCom AI Bot | ✅ | |
| 개인 WeChat | ✅ | |
+| Satori | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
From 682962cc47d27360e3acb26aaf95ff6994f2f2ea Mon Sep 17 00:00:00 2001
From: Typer_Body
Date: Thu, 12 Feb 2026 02:51:54 +0800
Subject: [PATCH 15/37] Add Satori to supported platforms list
---
README_RU.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README_RU.md b/README_RU.md
index fa5ce663..6e75055e 100644
--- a/README_RU.md
+++ b/README_RU.md
@@ -111,6 +111,7 @@ LangBot добавлен в BTPanel. Если у вас установлен BTP
| WeComCS | ✅ | |
| WeCom AI Bot | ✅ | |
| Личный WeChat | ✅ | |
+| Satori | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
From 995c852f0a1393cb6a51c5d4290ca9059d290149 Mon Sep 17 00:00:00 2001
From: Typer_Body
Date: Thu, 12 Feb 2026 02:52:26 +0800
Subject: [PATCH 16/37] Add Satori to the supported platforms list
---
README_VI.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README_VI.md b/README_VI.md
index 29f33aa7..008d2328 100644
--- a/README_VI.md
+++ b/README_VI.md
@@ -111,6 +111,7 @@ Hoặc truy cập môi trường demo: https://demo.langbot.dev/
| WeComCS | ✅ | |
| WeCom AI Bot | ✅ | |
| WeChat Cá nhân | ✅ | |
+| Satori | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
From 7f9e8ecac14828a8ca11a6929a9eb1e0930fa126 Mon Sep 17 00:00:00 2001
From: Typer_Body
Date: Thu, 12 Feb 2026 22:12:28 +0800
Subject: [PATCH 17/37] Add files via upload
---
README_TW.md | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/README_TW.md b/README_TW.md
index 4da6878b..bddaf1e9 100644
--- a/README_TW.md
+++ b/README_TW.md
@@ -111,7 +111,6 @@ docker compose up -d
| 企微對外客服 | ✅ | |
| 企微智能機器人 | ✅ | |
| 微信公眾號 | ✅ | |
-| Satori | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
@@ -162,4 +161,4 @@ docker compose up -d
-
+
\ No newline at end of file
From 18083e9160bee095d372d7da0a1af7274ffe9221 Mon Sep 17 00:00:00 2001
From: Typer_Body
Date: Thu, 12 Feb 2026 22:12:53 +0800
Subject: [PATCH 18/37] Update README_TW.md
---
README_TW.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/README_TW.md b/README_TW.md
index bddaf1e9..4da6878b 100644
--- a/README_TW.md
+++ b/README_TW.md
@@ -111,6 +111,7 @@ docker compose up -d
| 企微對外客服 | ✅ | |
| 企微智能機器人 | ✅ | |
| 微信公眾號 | ✅ | |
+| Satori | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
@@ -161,4 +162,4 @@ docker compose up -d
-
\ No newline at end of file
+
From d855d29c1514c2afc9d02c65c6597c8550af49f0 Mon Sep 17 00:00:00 2001
From: Typer_Body
Date: Thu, 12 Feb 2026 22:25:14 +0800
Subject: [PATCH 19/37] Add files via upload
---
src/langbot/pkg/platform/sources/satori.py | 549 ++++++++++-----------
1 file changed, 261 insertions(+), 288 deletions(-)
diff --git a/src/langbot/pkg/platform/sources/satori.py b/src/langbot/pkg/platform/sources/satori.py
index 31ac21dd..43db1aa3 100644
--- a/src/langbot/pkg/platform/sources/satori.py
+++ b/src/langbot/pkg/platform/sources/satori.py
@@ -24,40 +24,38 @@ class SatoriMessageConverter(abstract_platform_adapter.AbstractMessageConverter)
"""Convert between LangBot MessageChain and Satori message format"""
@staticmethod
- async def yiri2target(
- message_chain: platform_message.MessageChain, adapter: "SatoriAdapter"
- ) -> str:
+ async def yiri2target(message_chain: platform_message.MessageChain, adapter: 'SatoriAdapter') -> str:
"""Convert LangBot MessageChain to Satori message format"""
content_parts = []
for component in message_chain:
if isinstance(component, platform_message.Plain):
- text = component.text.replace("&", "&").replace("<", "<").replace(">", ">")
+ text = component.text.replace('&', '&').replace('<', '<').replace('>', '>')
content_parts.append(text)
elif isinstance(component, platform_message.Image):
# Prefer URL over base64 to avoid buffer overflow issues with large images
if component.url:
content_parts.append(f' ')
- elif hasattr(component, "base64") and component.base64:
+ elif hasattr(component, 'base64') and component.base64:
# Process base64 data
base64_data = component.base64
# Remove whitespace that might corrupt the data
base64_data = base64_data.replace('\n', '').replace('\r', '').replace(' ', '')
-
+
# Check size - if too large, try to upload
MAX_INLINE_SIZE = 32 * 1024 # 32KB limit for inline base64
-
+
# Extract raw base64 and mime type
raw_b64 = base64_data
- mime_type = "image/png"
- if base64_data.startswith("data:"):
+ mime_type = 'image/png'
+ if base64_data.startswith('data:'):
try:
header, raw_b64 = base64_data.split(',', 1)
if ';' in header:
mime_type = header.split(':')[1].split(';')[0]
except (ValueError, IndexError):
pass
-
+
if len(raw_b64) > MAX_INLINE_SIZE:
# Try to upload large image
try:
@@ -68,14 +66,14 @@ class SatoriMessageConverter(abstract_platform_adapter.AbstractMessageConverter)
image_bytes = base64.b64decode(raw_b64)
uploaded_url = await adapter.upload_image(image_bytes, mime_type)
if uploaded_url:
- await adapter.logger.info(f"Satori 图片上传成功: {len(image_bytes)} 字节")
+ await adapter.logger.info(f'Satori 图片上传成功: {len(image_bytes)} 字节')
content_parts.append(f' ')
else:
# Upload failed, use inline (may fail)
- await adapter.logger.warning("Satori 图片上传失败,使用内联模式")
+ await adapter.logger.warning('Satori 图片上传失败,使用内联模式')
content_parts.append(f' ')
except Exception as e:
- await adapter.logger.error(f"Satori 图片处理失败: {e}")
+ await adapter.logger.error(f'Satori 图片处理失败: {e}')
content_parts.append(f' ')
else:
# Small image, use inline
@@ -100,64 +98,64 @@ class SatoriMessageConverter(abstract_platform_adapter.AbstractMessageConverter)
if hasattr(component, 'url') and component.url:
content_parts.append(f' ')
- return "".join(content_parts)
+ return ''.join(content_parts)
@staticmethod
async def target2yiri(
- message_data: dict, adapter: "SatoriAdapter", bot_account_id: str = ""
+ message_data: dict, adapter: 'SatoriAdapter', bot_account_id: str = ''
) -> platform_message.MessageChain:
"""Convert Satori message to LangBot MessageChain
-
+
Parses Satori's XML-like message format and converts to LangBot MessageChain.
Handles text, images, mentions, replies, quotes, emoticons, audio, and files.
"""
- content = message_data.get("content", "")
+ content = message_data.get('content', '')
components = []
-
+
if content:
# HTML实体解码 - 注意顺序:先解码 & 再解码其他实体
# 这样可以正确处理 < -> < -> <
- content = content.replace("&", "&").replace("<", "<").replace(">", ">")
-
+ content = content.replace('&', '&').replace('<', '<').replace('>', '>')
+
# 定义各种消息组件的正则模式 - 支持更灵活的属性顺序
# 使用 (?:...) 非捕获组来支持可选属性
patterns = [
# 图片 - 支持 src 在任意位置
- (r' ]*src=["\']([^"\']+)["\'][^>]*/?\s*>', "image"),
+ (r' ]*src=["\']([^"\']+)["\'][^>]*/?\s*>', 'image'),
# @提及用户 - id 属性
- (r']*id=["\']([^"\']+)["\'][^>]*/?\s*>', "mention"),
+ (r']*id=["\']([^"\']+)["\'][^>]*/?\s*>', 'mention'),
# @全体 - type="all"
- (r']*type=["\']all["\'][^>]*/?\s*>', "mention_all"),
+ (r']*type=["\']all["\'][^>]*/?\s*>', 'mention_all'),
# 回复
- (r']*id=["\']([^"\']+)["\'][^>]*/?\s*>', "reply"),
+ (r']*id=["\']([^"\']+)["\'][^>]*/?\s*>', 'reply'),
# 引用
- (r']*id=["\']([^"\']+)["\'][^>]*/?\s*>', "quote"),
+ (r']*id=["\']([^"\']+)["\'][^>]*/?\s*>', 'quote'),
# 表情
- (r']*id=["\']([^"\']+)["\'][^>]*/?\s*>', "emoticon"),
- (r']*id=["\']([^"\']+)["\'][^>]*/?\s*>', "face"),
+ (r']*id=["\']([^"\']+)["\'][^>]*/?\s*>', 'emoticon'),
+ (r']*id=["\']([^"\']+)["\'][^>]*/?\s*>', 'face'),
# 音频
- (r']*src=["\']([^"\']+)["\'][^>]*/?\s*>', "audio"),
- (r']*(?:src|url)=["\']([^"\']+)["\'][^>]*/?\s*>', "audio"),
+ (r']*src=["\']([^"\']+)["\'][^>]*/?\s*>', 'audio'),
+ (r']*(?:src|url)=["\']([^"\']+)["\'][^>]*/?\s*>', 'audio'),
# 视频
- (r']*src=["\']([^"\']+)["\'][^>]*/?\s*>', "video"),
+ (r']*src=["\']([^"\']+)["\'][^>]*/?\s*>', 'video'),
# 文件 - 支持 url 或 src 属性
- (r']*(?:url|src)=["\']([^"\']+)["\'][^>]*/?\s*>', "file"),
+ (r']*(?:url|src)=["\']([^"\']+)["\'][^>]*/?\s*>', 'file'),
]
-
+
# 构建联合正则表达式
combined_pattern = '|'.join([f'({p[0]})' for p in patterns])
-
+
# 分割消息内容,按顺序处理各种组件
pos = 0
for match in re.finditer(combined_pattern, content, re.IGNORECASE):
# 添加匹配前的纯文本
if pos < match.start():
- text = content[pos:match.start()]
+ text = content[pos : match.start()]
# 保留文本(包括空白),但跳过完全空的文本
if text:
components.append(platform_message.Plain(text=text))
-
+
# 处理匹配到的组件
match_text = match.group(0)
matched = False
@@ -165,58 +163,60 @@ class SatoriMessageConverter(abstract_platform_adapter.AbstractMessageConverter)
sub_match = re.search(pattern, match_text, re.IGNORECASE)
if sub_match:
matched = True
- if msg_type == "image":
+ if msg_type == 'image':
img_url = sub_match.group(1)
components.append(platform_message.Image(url=img_url))
- elif msg_type == "mention":
+ elif msg_type == 'mention':
target_id = sub_match.group(1)
components.append(platform_message.At(target=str(target_id)))
- elif msg_type == "mention_all":
+ elif msg_type == 'mention_all':
components.append(platform_message.AtAll())
- elif msg_type == "reply":
+ elif msg_type == 'reply':
reply_id = sub_match.group(1)
components.append(platform_message.Reply(id=str(reply_id)))
- elif msg_type == "quote":
+ elif msg_type == 'quote':
quote_id = sub_match.group(1)
# Quote requires origin field - use empty list as placeholder
components.append(platform_message.Quote(message_id=str(quote_id), origin=[]))
- elif msg_type == "emoticon" or msg_type == "face":
+ elif msg_type == 'emoticon' or msg_type == 'face':
emoticon_id = sub_match.group(1)
- components.append(platform_message.Face(face_id=str(emoticon_id), face_name=f"emoticon_{emoticon_id}"))
- elif msg_type == "audio":
+ components.append(
+ platform_message.Face(face_id=str(emoticon_id), face_name=f'emoticon_{emoticon_id}')
+ )
+ elif msg_type == 'audio':
audio_url = sub_match.group(1)
components.append(platform_message.Voice(url=audio_url))
- elif msg_type == "video":
+ elif msg_type == 'video':
# 视频作为文件处理
video_url = sub_match.group(1)
- components.append(platform_message.File(url=video_url, name="video"))
- elif msg_type == "file":
+ components.append(platform_message.File(url=video_url, name='video'))
+ elif msg_type == 'file':
file_url = sub_match.group(1)
# 尝试从标签中提取文件名
name_match = re.search(r'name=["\']([^"\']*)["\']', match_text, re.IGNORECASE)
- file_name = name_match.group(1) if name_match else ""
+ file_name = name_match.group(1) if name_match else ''
components.append(platform_message.File(url=file_url, name=file_name))
break
-
+
# 如果没有匹配到任何已知模式,将其作为纯文本
if not matched:
components.append(platform_message.Plain(text=match_text))
-
+
pos = match.end()
-
+
# 添加剩余的文本
if pos < len(content):
remaining_text = content[pos:]
# 保留文本(包括空白),但跳过完全空的文本
if remaining_text:
components.append(platform_message.Plain(text=remaining_text))
-
+
# 如果没有解析出任何组件,但内容不为空,则作为纯文本
if not components and content:
components.append(platform_message.Plain(text=content))
message_chain = platform_message.MessageChain(components)
- await adapter.logger.info(f"Satori 消息解析完成: 共 {len(components)} 个组件 内容长度={len(content)} 字符")
+ await adapter.logger.info(f'Satori 消息解析完成: 共 {len(components)} 个组件 内容长度={len(content)} 字符')
return message_chain
@@ -224,7 +224,7 @@ class SatoriEventConverter(abstract_platform_adapter.AbstractEventConverter):
"""Convert between Satori events and LangBot events"""
@staticmethod
- def _ensure_string(value: typing.Any, default: str = "") -> str:
+ def _ensure_string(value: typing.Any, default: str = '') -> str:
"""Ensure value is string type"""
if value is None:
return default
@@ -234,54 +234,52 @@ class SatoriEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def target2yiri(
- event_data: dict, adapter: "SatoriAdapter", bot_account_id: str = ""
+ event_data: dict, adapter: 'SatoriAdapter', bot_account_id: str = ''
) -> typing.Optional[platform_events.MessageEvent]:
"""Convert Satori event to LangBot event
-
+
This method is used for standalone event conversion.
Note: The adapter's convert_satori_message method is preferred for better handling.
"""
- event_type = event_data.get("type", "")
+ event_type = event_data.get('type', '')
- if event_type == "message-created":
- message = event_data.get("message", {})
- user = event_data.get("user", {})
- guild = event_data.get("guild")
- channel = event_data.get("channel", {})
- login = event_data.get("login", {})
+ if event_type == 'message-created':
+ message = event_data.get('message', {})
+ user = event_data.get('user', {})
+ guild = event_data.get('guild')
+ channel = event_data.get('channel', {})
+ login = event_data.get('login', {})
- user_name = SatoriEventConverter._ensure_string(
- user.get("name") or user.get("nick"), ""
- )
- user_id = SatoriEventConverter._ensure_string(user.get("id"), "")
- message_id = SatoriEventConverter._ensure_string(message.get("id"), "")
- message_content = SatoriEventConverter._ensure_string(
- message.get("content"), ""
- )
+ user_name = SatoriEventConverter._ensure_string(user.get('name') or user.get('nick'), '')
+ user_id = SatoriEventConverter._ensure_string(user.get('id'), '')
+ message_id = SatoriEventConverter._ensure_string(message.get('id'), '')
+ message_content = SatoriEventConverter._ensure_string(message.get('content'), '')
# Log received message
- await adapter.logger.info(f"Satori EventConverter 消息接收: 用户ID={user_id}, 用户名={user_name}, 内容长度={len(message_content)}")
+ await adapter.logger.info(
+ f'Satori EventConverter 消息接收: 用户ID={user_id}, 用户名={user_name}, 内容长度={len(message_content)}'
+ )
# Convert message content to MessageChain
message_chain = await SatoriMessageConverter.target2yiri(
- {"content": message_content}, adapter, bot_account_id
+ {'content': message_content}, adapter, bot_account_id
)
-
+
# Insert Source component at the beginning of the message chain
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.datetime.now()))
# Build original event object for source_platform_object
original_event = {
- "type": event_type,
- "message": message,
- "user": user,
- "channel": channel,
- "guild": guild,
- "login": login,
+ 'type': event_type,
+ 'message': message,
+ 'user': user,
+ 'channel': channel,
+ 'guild': guild,
+ 'login': login,
}
# Try to get timestamp from message or use current time
- msg_timestamp = message.get("timestamp") or message.get("created_at")
+ msg_timestamp = message.get('timestamp') or message.get('created_at')
if msg_timestamp:
try:
if isinstance(msg_timestamp, (int, float)):
@@ -289,7 +287,9 @@ class SatoriEventConverter(abstract_platform_adapter.AbstractEventConverter):
event_time = event_time // 1000 if event_time > 1e12 else event_time
else:
# Try parsing ISO format
- event_time = int(datetime.datetime.fromisoformat(str(msg_timestamp).replace('Z', '+00:00')).timestamp())
+ event_time = int(
+ datetime.datetime.fromisoformat(str(msg_timestamp).replace('Z', '+00:00')).timestamp()
+ )
except (ValueError, TypeError):
event_time = int(time.time())
else:
@@ -299,14 +299,14 @@ class SatoriEventConverter(abstract_platform_adapter.AbstractEventConverter):
# In Satori protocol:
# - channel.type = 0: TEXT channel (group/guild message)
# - channel.type = 1: DIRECT channel (private message)
- channel_type = channel.get("type")
- channel_id = SatoriEventConverter._ensure_string(channel.get("id"), "")
+ channel_type = channel.get('type')
+ channel_id = SatoriEventConverter._ensure_string(channel.get('id'), '')
# Check if it's a private/direct message
- is_private = (channel_type == 1)
+ is_private = channel_type == 1
# Check if it's a group message
- is_group = (guild and guild.get("id")) or (channel_type == 0)
+ is_group = (guild and guild.get('id')) or (channel_type == 0)
if is_private:
# Private/friend message
@@ -321,18 +321,20 @@ class SatoriEventConverter(abstract_platform_adapter.AbstractEventConverter):
time=event_time,
source_platform_object=original_event,
)
- await adapter.logger.info(f"Satori 私聊消息已构建: 用户ID={user_id}, 用户名={user_name}")
+ await adapter.logger.info(f'Satori 私聊消息已构建: 用户ID={user_id}, 用户名={user_name}')
return friend_message
elif is_group:
# Group message
# Use guild.id if available, otherwise use channel.id as group_id
- group_id = SatoriEventConverter._ensure_string(guild.get("id"), "") if guild and guild.get("id") else channel_id
- group_name = guild.get("name", "Unknown Group") if guild else "Unknown Group"
+ group_id = (
+ SatoriEventConverter._ensure_string(guild.get('id'), '')
+ if guild and guild.get('id')
+ else channel_id
+ )
+ group_name = guild.get('name', 'Unknown Group') if guild else 'Unknown Group'
group = platform_entities.Group(
- id=group_id,
- name=group_name,
- permission=platform_entities.Permission.Member
+ id=group_id, name=group_name, permission=platform_entities.Permission.Member
)
sender = platform_entities.GroupMember(
id=user_id,
@@ -347,7 +349,7 @@ class SatoriEventConverter(abstract_platform_adapter.AbstractEventConverter):
time=event_time,
source_platform_object=original_event,
)
- await adapter.logger.info(f"Satori 群消息已构建: 群ID={group_id}, 发送者={user_name}")
+ await adapter.logger.info(f'Satori 群消息已构建: 群ID={group_id}, 发送者={user_name}')
return group_message
else:
# Fallback: treat as private message if cannot determine type
@@ -362,7 +364,7 @@ class SatoriEventConverter(abstract_platform_adapter.AbstractEventConverter):
time=event_time,
source_platform_object=original_event,
)
- await adapter.logger.info(f"Satori 私聊消息已构建 (fallback): 用户ID={user_id}, 用户名={user_name}")
+ await adapter.logger.info(f'Satori 私聊消息已构建 (fallback): 用户ID={user_id}, 用户名={user_name}')
return friend_message
return None
@@ -371,30 +373,22 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""Satori protocol adapter for LangBot - Native implementation"""
ws: typing.Optional[typing.Any] = pydantic.Field(exclude=True, default=None)
- session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(
- exclude=True, default=None
- )
+ session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(exclude=True, default=None)
running: bool = pydantic.Field(exclude=True, default=False)
sequence: int = pydantic.Field(exclude=True, default=0)
logins: typing.List[dict] = pydantic.Field(exclude=True, default_factory=list)
ready_received: bool = pydantic.Field(exclude=True, default=False)
heartbeat_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
- listeners: typing.Dict[typing.Type, typing.Callable] = pydantic.Field(
- exclude=True, default_factory=dict
- )
+ listeners: typing.Dict[typing.Type, typing.Callable] = pydantic.Field(exclude=True, default_factory=dict)
- message_converter: SatoriMessageConverter = pydantic.Field(
- default_factory=SatoriMessageConverter
- )
- event_converter: SatoriEventConverter = pydantic.Field(
- default_factory=SatoriEventConverter
- )
+ message_converter: SatoriMessageConverter = pydantic.Field(default_factory=SatoriMessageConverter)
+ event_converter: SatoriEventConverter = pydantic.Field(default_factory=SatoriEventConverter)
- platform: str = pydantic.Field(exclude=True, default="llonebot")
- host: str = pydantic.Field(exclude=True, default="127.0.0.1")
- api_base_url: str = pydantic.Field(exclude=True, default="")
- token: str = pydantic.Field(exclude=True, default="")
- endpoint: str = pydantic.Field(exclude=True, default="")
+ platform: str = pydantic.Field(exclude=True, default='llonebot')
+ host: str = pydantic.Field(exclude=True, default='127.0.0.1')
+ api_base_url: str = pydantic.Field(exclude=True, default='')
+ token: str = pydantic.Field(exclude=True, default='')
+ endpoint: str = pydantic.Field(exclude=True, default='')
port: int = pydantic.Field(exclude=True, default=5600)
auto_reconnect: bool = pydantic.Field(exclude=True, default=True)
heartbeat_interval: int = pydantic.Field(exclude=True, default=10)
@@ -406,24 +400,18 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
logger: abstract_platform_logger.AbstractEventLogger,
):
"""Initialize Satori adapter"""
- host = config.get("host", "127.0.0.1")
- port = config.get("port", 5600)
+ host = config.get('host', '127.0.0.1')
+ port = config.get('port', 5600)
# 初始化基类
super().__init__(
config=config,
logger=logger,
- platform=config.get("platform", "llonebot"),
+ platform=config.get('platform', 'llonebot'),
host=host,
- api_base_url=config.get(
- "satori_api_base_url",
- f"http://{host}:{port}/v1"
- ),
- token=config.get("token", ""),
- endpoint=config.get(
- "satori_endpoint",
- f"ws://{host}:{port}/v1/events"
- ),
+ api_base_url=config.get('satori_api_base_url', f'http://{host}:{port}/v1'),
+ token=config.get('token', ''),
+ endpoint=config.get('satori_endpoint', f'ws://{host}:{port}/v1/events'),
auto_reconnect=True,
port=port,
heartbeat_interval=10,
@@ -435,9 +423,9 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if not ws:
return True
try:
- if hasattr(ws, "closed"):
+ if hasattr(ws, 'closed'):
return ws.closed
- if hasattr(ws, "close_code"):
+ if hasattr(ws, 'close_code'):
return ws.close_code is not None
return False
except AttributeError:
@@ -451,31 +439,31 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
retry_count = 0
max_retries = 10
- await self.logger.info(f"Satori 适配器启动中 - 连接到 {self.endpoint}")
+ await self.logger.info(f'Satori 适配器启动中 - 连接到 {self.endpoint}')
while self.running:
try:
await self.connect_websocket()
retry_count = 0
except websockets.exceptions.ConnectionClosed as e:
- await self.logger.warning(f"Satori WebSocket 连接关闭: {e}")
+ await self.logger.warning(f'Satori WebSocket 连接关闭: {e}')
retry_count += 1
except Exception as e:
- await self.logger.error(f"Satori WebSocket 连接失败: {e}")
+ await self.logger.error(f'Satori WebSocket 连接失败: {e}')
retry_count += 1
if not self.running:
break
if retry_count >= max_retries:
- await self.logger.error(f"达到最大重试次数 ({max_retries}),停止重试")
+ await self.logger.error(f'达到最大重试次数 ({max_retries}),停止重试')
break
if not self.auto_reconnect:
break
delay = min(self.reconnect_delay * (2 ** (retry_count - 1)), 60)
- await self.logger.info(f"{delay}秒后重新连接...")
+ await self.logger.info(f'{delay}秒后重新连接...')
await asyncio.sleep(delay)
if self.session:
@@ -483,11 +471,11 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def connect_websocket(self):
"""Connect to WebSocket"""
- await self.logger.info(f"Satori 正在连接到 WebSocket: {self.endpoint}")
- await self.logger.info(f"Satori HTTP API 地址: {self.api_base_url}")
+ await self.logger.info(f'Satori 正在连接到 WebSocket: {self.endpoint}')
+ await self.logger.info(f'Satori HTTP API 地址: {self.api_base_url}')
- if not self.endpoint.startswith(("ws://", "wss://")):
- raise ValueError(f"WebSocket URL必须以ws://或wss://开头: {self.endpoint}")
+ if not self.endpoint.startswith(('ws://', 'wss://')):
+ raise ValueError(f'WebSocket URL必须以ws://或wss://开头: {self.endpoint}')
try:
self.ws = await websockets.connect(self.endpoint)
@@ -501,13 +489,13 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
try:
await self.handle_message(message)
except Exception as e:
- await self.logger.error(f"Satori 处理消息异常: {e}")
+ await self.logger.error(f'Satori 处理消息异常: {e}')
except websockets.exceptions.ConnectionClosed as e:
- await self.logger.warning(f"Satori WebSocket 连接关闭: {e}")
+ await self.logger.warning(f'Satori WebSocket 连接关闭: {e}')
raise
except Exception as e:
- await self.logger.error(f"Satori WebSocket 连接异常: {e}")
+ await self.logger.error(f'Satori WebSocket 连接异常: {e}')
raise
finally:
if self.heartbeat_task:
@@ -520,32 +508,32 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
try:
await self.ws.close()
except Exception as e:
- await self.logger.error(f"Satori WebSocket 关闭异常: {e}")
+ await self.logger.error(f'Satori WebSocket 关闭异常: {e}')
async def send_identify(self):
"""Send IDENTIFY signal"""
if not self.ws:
- raise Exception("WebSocket连接未建立")
+ raise Exception('WebSocket连接未建立')
if self._is_websocket_closed(self.ws):
- raise Exception("WebSocket连接已关闭")
+ raise Exception('WebSocket连接已关闭')
identify_payload = {
- "op": 3, # IDENTIFY
- "body": {
- "token": str(self.token) if self.token else "",
+ 'op': 3, # IDENTIFY
+ 'body': {
+ 'token': str(self.token) if self.token else '',
},
}
if self.sequence > 0:
- identify_payload["body"]["sn"] = self.sequence
+ identify_payload['body']['sn'] = self.sequence
try:
message_str = json.dumps(identify_payload, ensure_ascii=False)
await self.ws.send(message_str)
- await self.logger.info("Satori IDENTIFY 信令已发送")
+ await self.logger.info('Satori IDENTIFY 信令已发送')
except Exception as e:
- await self.logger.error(f"发送 IDENTIFY 信令失败: {e}")
+ await self.logger.error(f'发送 IDENTIFY 信令失败: {e}')
raise
async def heartbeat_loop(self):
@@ -557,87 +545,85 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if self.ws and not self._is_websocket_closed(self.ws):
try:
ping_payload = {
- "op": 1, # PING
- "body": {},
+ 'op': 1, # PING
+ 'body': {},
}
await self.ws.send(json.dumps(ping_payload, ensure_ascii=False))
except Exception as e:
- await self.logger.error(f"Satori WebSocket 发送心跳失败: {e}")
+ await self.logger.error(f'Satori WebSocket 发送心跳失败: {e}')
break
else:
break
except asyncio.CancelledError:
pass
except Exception as e:
- await self.logger.error(f"心跳任务异常: {e}")
+ await self.logger.error(f'心跳任务异常: {e}')
async def handle_message(self, message: str):
"""Handle WebSocket message"""
try:
data = json.loads(message)
- op = data.get("op")
- body = data.get("body", {})
+ op = data.get('op')
+ body = data.get('body', {})
if op == 4: # READY
- self.logins = body.get("logins", [])
+ self.logins = body.get('logins', [])
self.ready_received = True
if self.logins:
for i, login in enumerate(self.logins):
- platform = login.get("platform", "")
- user = login.get("user", {})
- user_id = user.get("id", "")
- user_name = user.get("name", "")
+ platform = login.get('platform', '')
+ user = login.get('user', {})
+ user_id = user.get('id', '')
+ user_name = user.get('name', '')
await self.logger.info(
- f"Satori 连接成功 - Bot {i + 1}: platform={platform}, user_id={user_id}, user_name={user_name}"
+ f'Satori 连接成功 - Bot {i + 1}: platform={platform}, user_id={user_id}, user_name={user_name}'
)
- if "sn" in body:
- self.sequence = body["sn"]
+ if 'sn' in body:
+ self.sequence = body['sn']
elif op == 2: # PONG
pass
elif op == 0: # EVENT
await self.handle_event(body)
- if "sn" in body:
- self.sequence = body["sn"]
+ if 'sn' in body:
+ self.sequence = body['sn']
elif op == 5: # META
- if "sn" in body:
- self.sequence = body["sn"]
+ if 'sn' in body:
+ self.sequence = body['sn']
except json.JSONDecodeError as e:
- await self.logger.error(f"解析 WebSocket 消息失败: {e}, 消息内容: {message}")
+ await self.logger.error(f'解析 WebSocket 消息失败: {e}, 消息内容: {message}')
except Exception as e:
- await self.logger.error(f"处理 WebSocket 消息异常: {e}")
+ await self.logger.error(f'处理 WebSocket 消息异常: {e}')
async def handle_event(self, event_data: dict):
"""Handle event"""
try:
- event_type = event_data.get("type")
+ event_type = event_data.get('type')
- if event_type == "message-created":
- message = event_data.get("message", {})
- user = event_data.get("user", {})
- channel = event_data.get("channel", {})
- guild = event_data.get("guild")
- login = event_data.get("login", {})
+ if event_type == 'message-created':
+ message = event_data.get('message', {})
+ user = event_data.get('user', {})
+ channel = event_data.get('channel', {})
+ guild = event_data.get('guild')
+ login = event_data.get('login', {})
# Skip messages from self
- bot_user_id = login.get("user", {}).get("id")
- msg_user_id = user.get("id")
+ bot_user_id = login.get('user', {}).get('id')
+ msg_user_id = user.get('id')
if bot_user_id and msg_user_id and str(bot_user_id) == str(msg_user_id):
return
- lb_event = await self.convert_satori_message(
- message, user, channel, guild, login
- )
+ lb_event = await self.convert_satori_message(message, user, channel, guild, login)
if lb_event and type(lb_event) in self.listeners:
await self.listeners[type(lb_event)](lb_event, self)
except Exception as e:
- await self.logger.error(f"处理事件失败: {e}\n{traceback.format_exc()}")
+ await self.logger.error(f'处理事件失败: {e}\n{traceback.format_exc()}')
async def convert_satori_message(
self,
@@ -648,41 +634,41 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
login: dict,
) -> typing.Optional[platform_events.MessageEvent]:
"""Convert Satori message to LangBot event
-
+
This is the main method for converting Satori messages to LangBot events.
It handles both private and group messages based on channel.type and guild info.
"""
try:
# Extract basic info with type safety
- user_id = str(user.get("id", "") or "")
- user_name = str(user.get("name", "") or user.get("nick", "") or "")
- message_id = str(message.get("id", "") or "")
- message_content = str(message.get("content", "") or "")
+ user_id = str(user.get('id', '') or '')
+ user_name = str(user.get('name', '') or user.get('nick', '') or '')
+ message_id = str(message.get('id', '') or '')
+ message_content = str(message.get('content', '') or '')
# Log received message (truncate long content)
- log_content = message_content[:100] + "..." if len(message_content) > 100 else message_content
- await self.logger.info(f"Satori 消息接收: 用户ID={user_id}, 用户名={user_name}, 内容长度={len(message_content)}, 预览='{log_content}'")
+ log_content = message_content[:100] + '...' if len(message_content) > 100 else message_content
+ await self.logger.info(
+ f"Satori 消息接收: 用户ID={user_id}, 用户名={user_name}, 内容长度={len(message_content)}, 预览='{log_content}'"
+ )
# Convert message content
- message_chain = await SatoriMessageConverter.target2yiri(
- {"content": message_content}, self, ""
- )
+ message_chain = await SatoriMessageConverter.target2yiri({'content': message_content}, self, '')
# Insert Source component at the beginning of the message chain
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.datetime.now()))
# Build original event object for source_platform_object
original_event = {
- "type": "message-created",
- "message": message,
- "user": user,
- "channel": channel,
- "guild": guild,
- "login": login,
+ 'type': 'message-created',
+ 'message': message,
+ 'user': user,
+ 'channel': channel,
+ 'guild': guild,
+ 'login': login,
}
# Try to get timestamp from message or use current time
- msg_timestamp = message.get("timestamp") or message.get("created_at")
+ msg_timestamp = message.get('timestamp') or message.get('created_at')
if msg_timestamp:
try:
if isinstance(msg_timestamp, (int, float)):
@@ -690,7 +676,9 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
event_time = int(msg_timestamp) if msg_timestamp < 1e12 else int(msg_timestamp / 1000)
else:
# Try parsing ISO format
- event_time = int(datetime.datetime.fromisoformat(str(msg_timestamp).replace('Z', '+00:00')).timestamp())
+ event_time = int(
+ datetime.datetime.fromisoformat(str(msg_timestamp).replace('Z', '+00:00')).timestamp()
+ )
except (ValueError, TypeError):
event_time = int(time.time())
else:
@@ -701,18 +689,16 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# - channel.type = 0: TEXT channel (group/guild message)
# - channel.type = 1: DIRECT channel (private message)
# Some implementations (like LLOneBot) may not provide guild info for group chats
- channel_type = channel.get("type")
- channel_id = str(channel.get("id", "") or "")
+ channel_type = channel.get('type')
+ channel_id = str(channel.get('id', '') or '')
# Check if it's a private/direct message
# Private message: channel.type == 1, or no guild and no channel type (legacy)
- is_private = (channel_type == 1)
+ is_private = channel_type == 1
# Check if it's a group message
# Group message: has guild info, or channel.type == 0
- is_group = (guild and guild.get("id")) or (channel_type == 0)
-
- await self.logger.info(f"Satori 消息类型判断: channel_type={channel_type}, channel_id={channel_id}, is_private={is_private}, is_group={is_group}, has_guild={guild is not None}")
+ is_group = (guild and guild.get('id')) or (channel_type == 0)
if is_private:
# Private/friend message
@@ -727,18 +713,18 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
time=event_time,
source_platform_object=original_event,
)
- await self.logger.info(f"Satori 私聊消息已构建: 用户ID={user_id}, 用户名={user_name}, 组件数={len(message_chain)}")
+ await self.logger.info(
+ f'Satori 私聊消息已构建: 用户ID={user_id}, 用户名={user_name}, 组件数={len(message_chain)}'
+ )
return friend_message
elif is_group:
# Group message
# Use guild.id if available, otherwise use channel.id as group_id
- group_id = str(guild.get("id", "") or "") if guild and guild.get("id") else channel_id
- group_name = str(guild.get("name", "Unknown Group") if guild else "Unknown Group")
+ group_id = str(guild.get('id', '') or '') if guild and guild.get('id') else channel_id
+ group_name = str(guild.get('name', 'Unknown Group') if guild else 'Unknown Group')
group = platform_entities.Group(
- id=group_id,
- name=group_name,
- permission=platform_entities.Permission.Member
+ id=group_id, name=group_name, permission=platform_entities.Permission.Member
)
sender = platform_entities.GroupMember(
id=user_id,
@@ -753,11 +739,13 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
time=event_time,
source_platform_object=original_event,
)
- await self.logger.info(f"Satori 群消息已构建: 群ID={group_id}, 发送者={user_name}, 组件数={len(message_chain)}")
+ await self.logger.info(
+ f'Satori 群消息已构建: 群ID={group_id}, 发送者={user_name}, 组件数={len(message_chain)}'
+ )
return group_message
else:
# Fallback: treat as private message if cannot determine type
- await self.logger.warning(f"Satori 无法确定消息类型,使用私聊作为fallback: channel_type={channel_type}")
+ await self.logger.warning(f'Satori 无法确定消息类型,使用私聊作为fallback: channel_type={channel_type}')
sender = platform_entities.Friend(
id=user_id,
nickname=user_name,
@@ -769,15 +757,13 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
time=event_time,
source_platform_object=original_event,
)
- await self.logger.info(f"Satori 私聊消息已构建 (fallback): 用户ID={user_id}, 用户名={user_name}")
+ await self.logger.info(f'Satori 私聊消息已构建 (fallback): 用户ID={user_id}, 用户名={user_name}')
return friend_message
except Exception as e:
- await self.logger.error(f"转换 Satori 消息失败: {e}\n{traceback.format_exc()}")
+ await self.logger.error(f'转换 Satori 消息失败: {e}\n{traceback.format_exc()}')
return None
-
-
async def send_http_request(
self,
method: str,
@@ -788,89 +774,80 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
) -> typing.Optional[dict]:
"""Send HTTP request to Satori API"""
if not self.session:
- await self.logger.error("HTTP session 未初始化")
+ await self.logger.error('HTTP session 未初始化')
return None
- url = f"{self.api_base_url}{path}"
+ url = f'{self.api_base_url}{path}'
headers = {
- "Content-Type": "application/json",
- "Authorization": f"Bearer {self.token}",
+ 'Content-Type': 'application/json',
+ 'Authorization': f'Bearer {self.token}',
}
if platform:
- headers["Satori-Platform"] = platform
+ headers['Satori-Platform'] = platform
if user_id:
- headers["Satori-User-ID"] = user_id
+ headers['Satori-User-ID'] = user_id
try:
- async with self.session.request(
- method, url, headers=headers, json=data
- ) as response:
+ async with self.session.request(method, url, headers=headers, json=data) as response:
if response.status == 200:
return await response.json()
else:
text = await response.text()
- await self.logger.error(f"Satori API 请求失败: {response.status} - {text}")
+ await self.logger.error(f'Satori API 请求失败: {response.status} - {text}')
return None
except Exception as e:
- await self.logger.error(f"Satori API 请求异常: {e}")
+ await self.logger.error(f'Satori API 请求异常: {e}')
return None
async def upload_image(
self,
image_bytes: bytes,
- mime_type: str = "image/png",
+ mime_type: str = 'image/png',
) -> typing.Optional[str]:
"""Upload image to Satori server and return the URL
-
+
Uses multipart/form-data to upload the image file via upload.create API.
Returns the URL of the uploaded image, or None if upload fails.
"""
if not self.session:
- await self.logger.error("HTTP session 未初始化")
+ await self.logger.error('HTTP session 未初始化')
return None
- url = f"{self.api_base_url}/upload.create"
+ url = f'{self.api_base_url}/upload.create'
headers = {}
-
- if self.token:
- headers["Authorization"] = f"Bearer {self.token}"
- platform = ""
- user_id = ""
+ if self.token:
+ headers['Authorization'] = f'Bearer {self.token}'
+
+ platform = ''
+ user_id = ''
if self.logins:
current_login = self.logins[0]
- platform = current_login.get("platform", "")
- user = current_login.get("user", {})
- user_id = user.get("id", "")
+ platform = current_login.get('platform', '')
+ user = current_login.get('user', {})
+ user_id = user.get('id', '')
if platform:
- headers["Satori-Platform"] = platform
+ headers['Satori-Platform'] = platform
if user_id:
- headers["Satori-User-ID"] = user_id
+ headers['Satori-User-ID'] = user_id
try:
# Determine file extension from mime type
- ext = "png"
- if "jpeg" in mime_type or "jpg" in mime_type:
- ext = "jpg"
- elif "gif" in mime_type:
- ext = "gif"
- elif "webp" in mime_type:
- ext = "webp"
-
+ ext = 'png'
+ if 'jpeg' in mime_type or 'jpg' in mime_type:
+ ext = 'jpg'
+ elif 'gif' in mime_type:
+ ext = 'gif'
+ elif 'webp' in mime_type:
+ ext = 'webp'
+
# Create multipart form data
form_data = aiohttp.FormData()
- form_data.add_field(
- 'file',
- image_bytes,
- filename=f'image.{ext}',
- content_type=mime_type
- )
+ form_data.add_field('file', image_bytes, filename=f'image.{ext}', content_type=mime_type)
- async with self.session.post(
- url, headers=headers, data=form_data
- ) as response:
+ async with self.session.post(url, headers=headers, data=form_data) as response:
if response.status == 200:
result = await response.json()
# The response should contain the URL of the uploaded file
@@ -879,14 +856,14 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
elif isinstance(result, list) and len(result) > 0 and 'url' in result[0]:
return result[0]['url']
else:
- await self.logger.warning(f"Satori 图片上传响应格式未知: {result}")
+ await self.logger.warning(f'Satori 图片上传响应格式未知: {result}')
return None
else:
text = await response.text()
- await self.logger.error(f"Satori 图片上传失败: {response.status} - {text}")
+ await self.logger.error(f'Satori 图片上传失败: {response.status} - {text}')
return None
except Exception as e:
- await self.logger.error(f"Satori 图片上传异常: {e}")
+ await self.logger.error(f'Satori 图片上传异常: {e}')
return None
async def kill(self) -> bool:
@@ -901,7 +878,7 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
pass
if self.session:
await self.session.close()
- await self.logger.info("Satori 适配器已停止")
+ await self.logger.info('Satori 适配器已停止')
return True
async def send_message(
@@ -914,21 +891,19 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
try:
content = await self.message_converter.yiri2target(message, self)
- platform = ""
- user_id = ""
+ platform = ''
+ user_id = ''
if self.logins:
current_login = self.logins[0]
- platform = current_login.get("platform", "")
- user = current_login.get("user", {})
- user_id = user.get("id", "")
+ platform = current_login.get('platform', '')
+ user = current_login.get('user', {})
+ user_id = user.get('id', '')
- data = {"channel_id": target_id, "content": content}
- await self.send_http_request(
- "POST", "/message.create", data, platform, user_id
- )
+ data = {'channel_id': target_id, 'content': content}
+ await self.send_http_request('POST', '/message.create', data, platform, user_id)
except Exception as e:
- await self.logger.error(f"Satori 发送消息失败: {e}")
+ await self.logger.error(f'Satori 发送消息失败: {e}')
async def reply_message(
self,
@@ -941,49 +916,47 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
content = await self.message_converter.yiri2target(message, self)
# Try to get channel_id from source_platform_object first (Satori protocol needs original channel.id)
- channel_id = ""
+ channel_id = ''
if hasattr(message_source, 'source_platform_object') and message_source.source_platform_object:
source_obj = message_source.source_platform_object
if isinstance(source_obj, dict):
- channel = source_obj.get("channel", {})
- if channel and channel.get("id"):
- channel_id = str(channel.get("id"))
+ channel = source_obj.get('channel', {})
+ if channel and channel.get('id'):
+ channel_id = str(channel.get('id'))
# Fallback: get channel_id from message source
if not channel_id:
if isinstance(message_source, platform_events.GroupMessage):
# Group message: use group ID
- if hasattr(message_source.sender, "group") and hasattr(message_source.sender.group, "id"):
+ if hasattr(message_source.sender, 'group') and hasattr(message_source.sender.group, 'id'):
channel_id = message_source.sender.group.id
elif isinstance(message_source, platform_events.FriendMessage):
# Private message: use sender ID as channel_id
- if hasattr(message_source.sender, "id"):
+ if hasattr(message_source.sender, 'id'):
channel_id = message_source.sender.id
# Last fallback
if not channel_id:
- if hasattr(message_source, "sender") and hasattr(message_source.sender, "id"):
+ if hasattr(message_source, 'sender') and hasattr(message_source.sender, 'id'):
channel_id = message_source.sender.id
if not channel_id:
- await self.logger.error("无法获取频道ID")
+ await self.logger.error('无法获取频道ID')
return
- platform = ""
- user_id = ""
+ platform = ''
+ user_id = ''
if self.logins:
current_login = self.logins[0]
- platform = current_login.get("platform", "")
- user = current_login.get("user", {})
- user_id = user.get("id", "")
+ platform = current_login.get('platform', '')
+ user = current_login.get('user', {})
+ user_id = user.get('id', '')
- data = {"channel_id": channel_id, "content": content}
- await self.send_http_request(
- "POST", "/message.create", data, platform, user_id
- )
+ data = {'channel_id': channel_id, 'content': content}
+ await self.send_http_request('POST', '/message.create', data, platform, user_id)
except Exception as e:
- await self.logger.error(f"Satori 回复消息失败: {e}")
+ await self.logger.error(f'Satori 回复消息失败: {e}')
async def is_muted(self, group_id: int) -> bool:
"""Check if the bot is muted in a group"""
From 1f60d9c3d654fd227a9fca2611f774b9aafa7626 Mon Sep 17 00:00:00 2001
From: Typer_Body
Date: Thu, 12 Feb 2026 22:27:51 +0800
Subject: [PATCH 20/37] Add files via upload
From 809035daacc9584c84920c474096d4c01b2e3db4 Mon Sep 17 00:00:00 2001
From: Junyan Chin
Date: Tue, 17 Feb 2026 22:19:51 +0800
Subject: [PATCH 21/37] Update src/langbot/pkg/platform/sources/satori.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
src/langbot/pkg/platform/sources/satori.py | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/langbot/pkg/platform/sources/satori.py b/src/langbot/pkg/platform/sources/satori.py
index 43db1aa3..edf2da0b 100644
--- a/src/langbot/pkg/platform/sources/satori.py
+++ b/src/langbot/pkg/platform/sources/satori.py
@@ -645,10 +645,15 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
message_id = str(message.get('id', '') or '')
message_content = str(message.get('content', '') or '')
- # Log received message (truncate long content)
+ # Log received message (truncate long content for debug preview)
log_content = message_content[:100] + '...' if len(message_content) > 100 else message_content
+ # At info level, avoid logging raw content to protect privacy and reduce log volume
await self.logger.info(
- f"Satori 消息接收: 用户ID={user_id}, 用户名={user_name}, 内容长度={len(message_content)}, 预览='{log_content}'"
+ f"Satori 消息接收: 用户ID={user_id}, 用户名={user_name}, 内容长度={len(message_content)}, 消息ID={message_id}"
+ )
+ # Detailed content preview only at debug level
+ await self.logger.debug(
+ f"Satori 消息内容预览: 用户ID={user_id}, 消息ID={message_id}, 预览='{log_content}'"
)
# Convert message content
From deabb19389e44d3cb971b5a22d4bc20c039a1b55 Mon Sep 17 00:00:00 2001
From: Junyan Chin
Date: Tue, 17 Feb 2026 22:20:27 +0800
Subject: [PATCH 22/37] Update src/langbot/pkg/platform/sources/satori.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
src/langbot/pkg/platform/sources/satori.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/langbot/pkg/platform/sources/satori.py b/src/langbot/pkg/platform/sources/satori.py
index edf2da0b..11df382a 100644
--- a/src/langbot/pkg/platform/sources/satori.py
+++ b/src/langbot/pkg/platform/sources/satori.py
@@ -216,7 +216,7 @@ class SatoriMessageConverter(abstract_platform_adapter.AbstractMessageConverter)
components.append(platform_message.Plain(text=content))
message_chain = platform_message.MessageChain(components)
- await adapter.logger.info(f'Satori 消息解析完成: 共 {len(components)} 个组件 内容长度={len(content)} 字符')
+ await adapter.logger.debug(f'Satori 消息解析完成: 共 {len(components)} 个组件 内容长度={len(content)} 字符')
return message_chain
From 05f40e72ffd64bbd1f39b53a68cde7f77f4bf824 Mon Sep 17 00:00:00 2001
From: Typer_Body
Date: Wed, 18 Feb 2026 16:46:53 +0800
Subject: [PATCH 23/37] Add files via upload
---
README.md | 228 ++++++++++++++++++++++++---------------------------
README_KO.md | 195 +++++++++++++++++++++++--------------------
README_RU.md | 195 +++++++++++++++++++++++--------------------
README_TW.md | 215 ++++++++++++++++++++++++++----------------------
README_VI.md | 193 +++++++++++++++++++++++--------------------
5 files changed, 539 insertions(+), 487 deletions(-)
diff --git a/README.md b/README.md
index 12d805bc..0592319c 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,3 @@
-
@@ -6,44 +5,60 @@
-
+
-
使用 LangBot 快速构建、调试、部署即时通信机器人。
+
Production-grade platform for building agentic IM bots.
+
Quickly build, debug, and ship AI bots to Slack, Discord, Telegram, WeChat, and more.
-[English](README_EN.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)
+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://qm.qq.com/q/DxZZcNxM1W)
[](https://deepwiki.com/langbot-app/LangBot)
[](https://github.com/langbot-app/LangBot/releases/latest)
-[](https://gitcode.com/RockChinQ/LangBot)
+[](https://github.com/langbot-app/LangBot/stargazers)
-
项目主页 |
-
规格特性 |
-
部署文档 |
-
API 集成 |
-
插件市场 |
-
路线图
+
Website |
+
Features |
+
Docs |
+
API |
+
Plugin Market |
+
Roadmap
+---
-## 📦 开始使用
+## 🚀 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` 一键启动(需要先安装 [uv](https://docs.astral.sh/uv/getting-started/installation/)):
+### 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).
+- **🤖 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://docs.langbot.app/en/insight/features.html)
+
+---
+
+## 📦 Quick Start
+
+### One-Line Launch
```bash
uvx langbot
```
-访问 http://localhost:5300 即可开始使用。
+> Requires [uv](https://docs.astral.sh/uv/getting-started/installation/). Visit http://localhost:5300 — done.
-#### Docker Compose 部署
+### Docker Compose
```bash
git clone https://github.com/langbot-app/LangBot
@@ -51,128 +66,101 @@ cd LangBot/docker
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)
-
-#### Railway 云部署
+### One-Click Cloud Deploy
+[](https://zeabur.com/en-US/templates/ZKTBDH)
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
-#### 手动部署
+**More options:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
-直接使用发行版运行,查看文档[手动部署](https://docs.langbot.app/zh/deploy/langbot/manual.html)。
+---
-#### Kubernetes 部署
+## ✨ Supported Platforms
-参考 [Kubernetes 部署](./docker/README_K8S.md) 文档。
-
-## 😎 保持更新
-
-点击仓库右上角 Star 和 Watch 按钮,获取最新动态。
-
-
-
-## ✨ 特性
-
-
-
-
-- 💬 大模型对话、Agent:支持多种大模型,适配群聊和私聊;具有多轮对话、工具调用、多模态、流式输出能力,自带 RAG(知识库)实现,并深度适配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)等 LLMOps 平台。
-- 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram、KOOK、Slack、LINE 等平台。
-- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。
-- 🧩 插件扩展、活跃社区:高稳定性、高安全性的生产级插件系统,支持事件驱动、组件扩展等插件机制;适配 Anthropic [MCP 协议](https://modelcontextprotocol.io/);目前已有数百个插件。
-- 😻 Web 管理面板:提供先进的 WebUI 管理面板,用最直观的方式配置、管理、监控机器人。
-- 📊 生产级特性:支持多流水线配置,不同机器人用于不同应用场景。具有全面的监控和异常处理能力。已被多家企业采用。
-
-详细规格特性请访问[文档](https://docs.langbot.app/zh/insight/features.html)。
-
-或访问 demo 环境:https://demo.langbot.dev/
- - 登录信息:邮箱:`demo@langbot.app` 密码:`langbot123456`
- - 注意:仅展示 WebUI 效果,公开环境,请不要在其中填入您的任何敏感信息。
-
-### 消息平台
-
-| 平台 | 状态 | 备注 |
-| --- | --- | --- |
-| QQ 个人号 | ✅ | QQ 个人号私聊、群聊 |
-| QQ 官方机器人 | ✅ | QQ 官方机器人,支持频道、私聊、群聊 |
-| 企业微信 | ✅ | |
-| 企微对外客服 | ✅ | |
-| 企微智能机器人 | ✅ | |
-| 个人微信 | ✅ | |
-| 微信公众号 | ✅ | |
-| 飞书 | ✅ | |
-| 钉钉 | ✅ | |
-| KOOK | ✅ | |
-| Satori | ✅ | |
+| Platform | Status | Notes |
+|----------|--------|-------|
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
+| QQ | ✅ | Personal & Official API |
+| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
+| WeChat | ✅ | Personal & Official Account |
+| Lark | ✅ | |
+| DingTalk | ✅ | |
+| KOOK | ✅ | |
-### 大模型能力
+---
-| 模型 | 状态 | 备注 |
-| --- | --- | --- |
-| [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 |
+## 🤖 Supported LLMs & Integrations
-### TTS
+| 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 | ✅ |
-| 平台/模型 | 备注 |
-| --- | --- |
-| [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) |
+[→ View all integrations](https://docs.langbot.app/en/insight/features.html)
-### 文生图
+---
-| 平台/模型 | 备注 |
-| --- | --- |
-| 阿里云百炼 | [插件](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin)
+## 🌟 Why LangBot?
-## 😘 社区贡献
+| Use Case | How LangBot Helps |
+|----------|-------------------|
+| **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 |
-感谢以下[代码贡献者](https://github.com/langbot-app/LangBot/graphs/contributors)和社区里其他成员对 LangBot 的贡献:
+---
+
+## 🎮 Live Demo
+
+**Try it now:** https://demo.langbot.dev/
+- Email: `demo@langbot.app`
+- Password: `langbot123456`
+
+*Note: Public demo environment. Do not enter sensitive information.*
+
+---
+
+## 🤝 Community
+
+[](https://discord.gg/wdNEHETs87)
+
+- 💬 [Discord Community](https://discord.gg/wdNEHETs87)
+
+---
+
+## ⭐ 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:
-
-
diff --git a/README_KO.md b/README_KO.md
index 1ad9cd40..b8cf9795 100644
--- a/README_KO.md
+++ b/README_KO.md
@@ -7,19 +7,21 @@
-LangBot으로 IM 봇을 빠르게 구축, 디버그 및 배포하세요.
+AI 에이전트 IM 봇 구축을 위한 프로덕션 등급 플랫폼.
+Slack, Discord, Telegram, WeChat 등에 AI 봇을 빠르게 구축, 디버그 및 배포.
-[English](README_EN.md) / [简体中文](README.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)
+[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)
+[](https://github.com/langbot-app/LangBot/stargazers)
홈 |
-기능 사양 |
-배포 |
-API 통합 |
+기능 |
+문서 |
+API |
플러그인 마켓 |
로드맵
@@ -27,19 +29,36 @@
-## 📦 시작하기
+---
-#### 빠른 시작
+## 🚀 LangBot이란?
-`uvx`를 사용하여 한 명령으로 시작하세요 ([uv](https://docs.astral.sh/uv/getting-started/installation/) 설치 필요):
+LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈소스 프로덕션 등급 플랫폼**입니다. 대규모 언어 모델(LLM)을 모든 채팅 플랫폼에 연결하여 대화, 작업 실행, 기존 워크플로우와의 통합이 가능한 지능형 에이전트를 만들 수 있습니다.
+
+### 핵심 기능
+
+- **💬 AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) 심층 통합.
+- **🤖 유니버설 IM 플랫폼 지원** — 단일 코드베이스로 Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK 지원.
+- **🛠️ 프로덕션 레디** — 접근 제어, 속도 제한, 민감어 필터링, 종합 모니터링 및 예외 처리. 기업 환경에서 검증됨.
+- **🧩 플러그인 생태계** — 수백 개의 플러그인, 이벤트 기반 아키텍처, 컴포넌트 확장, [MCP 프로토콜](https://modelcontextprotocol.io/) 지원.
+- **😻 웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
+- **📊 멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
+
+[→ 모든 기능 자세히 보기](https://docs.langbot.app/en/insight/features.html)
+
+---
+
+## 📦 빠른 시작
+
+### 원라인 실행
```bash
uvx langbot
```
-http://localhost:5300을 방문하여 사용을 시작하세요.
+> [uv](https://docs.astral.sh/uv/getting-started/installation/) 설치 필요. http://localhost:5300 방문 — 완료.
-#### Docker Compose 배포
+### Docker Compose
```bash
git clone https://github.com/langbot-app/LangBot
@@ -47,104 +66,100 @@ cd LangBot/docker
docker compose up -d
```
-http://localhost:5300을 방문하여 사용을 시작하세요.
-
-자세한 문서는 [Docker 배포](https://docs.langbot.app/en/deploy/langbot/docker.html)를 참조하세요.
-
-#### BTPanel 원클릭 배포
-
-LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [문서](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html)를 사용하여 사용할 수 있습니다.
-
-#### Zeabur 클라우드 배포
-
-커뮤니티에서 제공하는 Zeabur 템플릿입니다.
+### 원클릭 클라우드 배포
[](https://zeabur.com/en-US/templates/ZKTBDH)
-
-#### Railway 클라우드 배포
-
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
-#### 기타 배포 방법
+**더 많은 옵션:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [수동 배포](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
-릴리스 버전을 직접 사용하여 실행하려면 [수동 배포](https://docs.langbot.app/en/deploy/langbot/manual.html) 문서를 참조하세요.
+---
-#### Kubernetes 배포
-
-[Kubernetes 배포](./docker/README_K8S.md) 문서를 참조하세요.
-
-## 😎 최신 정보 받기
-
-리포지토리 오른쪽 상단의 Star 및 Watch 버튼을 클릭하여 최신 업데이트를 받으세요.
-
-
-
-## ✨ 기능
-
-
-
-
-- 💬 LLM / Agent와 채팅: 여러 LLM을 지원하며 그룹 채팅 및 개인 채팅에 적응; 멀티 라운드 대화, 도구 호출, 멀티모달, 스트리밍 출력 기능을 지원합니다. 내장된 RAG(지식 베이스) 구현 및 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)등의 LLMOps 플랫폼과 깊이 통합됩니다.
-- 🤖 다중 플랫폼 지원: 현재 QQ, QQ Channel, WeCom, 개인 WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE 등을 지원합니다.
-- 🛠️ 높은 안정성, 풍부한 기능: 네이티브 액세스 제어, 속도 제한, 민감한 단어 필터링 등의 메커니즘; 사용하기 쉽고 여러 배포 방법을 지원합니다.
-- 🧩 플러그인 확장, 활발한 커뮤니티: 고안정성, 고보안 생산 수준의 플러그인 시스템; 이벤트 기반, 컴포넌트 확장 등의 플러그인 메커니즘을 지원; Anthropic [MCP 프로토콜](https://modelcontextprotocol.io/) 통합; 현재 수백 개의 플러그인이 있습니다.
-- 😻 웹 UI: 브라우저를 통해 LangBot 인스턴스 관리를 지원합니다. 구성 파일을 수동으로 작성할 필요가 없습니다.
-- 📊 생산 수준의 기능: 여러 파이프라인 구성을 지원하며 다양한 시나리오에 대해 다른 봇을 사용할 수 있습니다. 포괄적인 모니터링 및 예외 처리 기능을 갖추고 있습니다.
-
-더 자세한 사양은 [문서](https://docs.langbot.app/en/insight/features.html)를 참조하세요.
-
-또는 데모 환경을 방문하세요: https://demo.langbot.dev/
- - 로그인 정보: 이메일: `demo@langbot.app` 비밀번호: `langbot123456`
- - 참고: WebUI 데모 전용이므로 공개 환경에서는 민감한 정보를 입력하지 마세요.
-
-### 메시징 플랫폼
+## ✨ 지원 플랫폼
| 플랫폼 | 상태 | 비고 |
-| --- | --- | --- |
+|--------|------|------|
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
-| 개인 QQ | ✅ | |
-| QQ 공식 API | ✅ | |
-| WeCom | ✅ | |
-| WeComCS | ✅ | |
-| WeCom AI Bot | ✅ | |
-| 개인 WeChat | ✅ | |
-| Satori | ✅ | |
-| KOOK | ✅ | |
+| QQ | ✅ | 개인 및 공식 API |
+| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
+| WeChat | ✅ | 개인 및 공식 계정 |
| Lark | ✅ | |
| DingTalk | ✅ | |
+| KOOK | ✅ | |
-### LLMs
+---
-| 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) | ✅ | LLM 및 GPU 리소스 플랫폼 |
-| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | LLM 및 GPU 리소스 플랫폼 |
-| [接口 AI](https://jiekou.ai/) | ✅ | LLM 집계 플랫폼 |
-| [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 프로토콜을 통한 도구 액세스 지원 |
+## 🤖 지원 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) | 게이트웨이 | ✅ |
-다음 [코드 기여자](https://github.com/langbot-app/LangBot/graphs/contributors) 및 커뮤니티의 다른 구성원들의 LangBot 기여에 감사드립니다:
+[→ 모든 통합 보기](https://docs.langbot.app/en/insight/features.html)
+
+---
+
+## 🌟 왜 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)분들께 감사드립니다:
diff --git a/README_RU.md b/README_RU.md
index 6e75055e..3215357d 100644
--- a/README_RU.md
+++ b/README_RU.md
@@ -7,19 +7,21 @@
-Быстро создавайте, отлаживайте и развертывайте IM-ботов с LangBot.
+Платформа производственного уровня для создания агентных IM-ботов.
+Быстро создавайте, отлаживайте и развертывайте ИИ-ботов в Slack, Discord, Telegram, WeChat и других платформах.
-[English](README_EN.md) / [简体中文](README.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)
+[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)
+[](https://github.com/langbot-app/LangBot/stargazers)
Главная |
-Характеристики |
-Развертывание |
-Интеграция API |
+Возможности |
+Документация |
+API |
Магазин плагинов |
Дорожная карта
@@ -27,19 +29,36 @@