From 1f7894c7686b8af7b23b112b164b653d73a3cbdb Mon Sep 17 00:00:00 2001 From: eric Date: Tue, 31 Mar 2026 07:04:37 -0500 Subject: [PATCH] 'init' --- __pycache__/config.cpython-312.pyc | Bin 0 -> 289 bytes __pycache__/crypto_utils.cpython-312.pyc | Bin 0 -> 3419 bytes __pycache__/main.cpython-312.pyc | Bin 0 -> 22860 bytes config.py | 3 + crypto_utils.py | 62 +++ main.py | 577 +++++++++++++++++++++++ requirements.txt | 4 + run.py | 6 + token_client.py | 53 +++ 9 files changed, 705 insertions(+) create mode 100644 __pycache__/config.cpython-312.pyc create mode 100644 __pycache__/crypto_utils.cpython-312.pyc create mode 100644 __pycache__/main.cpython-312.pyc create mode 100644 config.py create mode 100644 crypto_utils.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 token_client.py diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a9ec32de7267dde4f65741a304fee1e1a81aa6b0 GIT binary patch literal 289 zcmX@j%ge<81PLXlGdqCvV-N=h7@>^M0zk%eh7^VUT@P)z8`A#naC{-qAHU-rF^DCBtWs6MtzqTg8MHrxq2*B&OtM=EblIY~;;_lhPbtkwwJYKVY6N+(SQJQnU}j`wyum5ez;=T}qk;8? Nu){?Lfg(V4W6ipkp}-_CeDD zE1bZ=in;0-WrbRh)e6o>WG;|7g*uSAMVD{@Mh|o^bXIhX)(Z8oUN=qo4sH_%27Hvx zd@q@Zh%lj_>mS_aL8@DYpc2$sEGkJH3sFR~+-Mj{-}wfR1*C%8j$r#uWq}UjCfgKLI z{3H+!tvXyOe5+aOU9~w<-<`dlZp>JI(Y#vcO>wJSU7ATZrt4GZbJjyUppG8#*9ro| z-ygta0VzbmXbO=UO-1+15vs1<0H1CEcpxrFAw)EHIKRjC^wzX5WH3pL=Qbw|(T z7kV!R1}_fv>vTkn>CDKqBFdEjIfK!7GNuH;@oK2B6Ab81)BlS12SMh6EUX ze%qK8?)e^s^QXG<_0MQm!0TT&WsYa+3ZCZ0?!2cf&voUjUAT@|bBnQ1oP;qD6y?B# zIQ{s5OoA>1WEUJ^a(Vk2fd_H1;`9u)Hy9Nu0H+i1z$v!Mc7d*}%@SZ)1?Cus;~Oh0O5j5&efa}knyl=2NOc?NPJ&ygazaS1OX4)Yv5?)kor3-5 zM~dm6Z{Pm7_yL@44vt>^%D}c8Hv6nPYaU9sk94#NBHWCK$>r*f?*}7Eu^;0&_R;td z5!uQYrdy;*ncx!wWFo{fCMuQd3I+8p3V!<_-s~KFiyVKtEsnplBo>k71?lBU`M35sS zCB{V@fD?!IqJkuXkxc)B$zJTJyJ;~D`ZJapx|&{Ll7jnMHRWckRn&x`M(=N`MIq(I zXknw8S2%N2>HFnGo?%obOwwszQ>_&&&aete5TmJVRjRV#0~L976n1?YMDJJz9x_Q~ z^lX>UZp0lQ14Z0G2ysuRW8!ta=E@6w{ej+|Ufl*4O=_G`$aL1ccApLNWCOWse4LOI zK=j&5ut~AdS(DT4WecJpldJ+DkX8l*fk}BiV4@3?LfHsi{t*zU8J^R*ruKrTEOf=f4_ClLhw^0444~89)~c+!HI@ z**tf4ajd{~Z>zMFfn_!8w>J{!@^zJKXY7lzAJcYN- zlKdwy4ifNzS z0blhTm_4ooXZUWKkj7#`_`MUs(3QUSW-#gF`Qp-i@zp#0xHx4f73XKSw3p$lX7)XhxDC#rh{0zDNfsPc>@e+$%Zp~3*t<>R6QwhP4?cX6o$z`FQ$uK1ZL$+y$ z3?+x1I=tvDAsChpE+v=6B{lza-wxqRM_8&U+gd^}EH@g%CyhIVD|uL|Gi!)Cmtiw- npLoeiXUS7Tb!1=#hDCE&1`ELbZO|BUKO8p4pztLT8xH;lqdP=C literal 0 HcmV?d00001 diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..55bbde4a90d1f8194be994673e2c9dcb98a60313 GIT binary patch literal 22860 zcmeHvX;>WBwP0263pD!4kWljm$(D_5 z$wIblkVPyx@q%N?!m;x#%WIr`i6?pSYtp=-)6sl!ChA7|=9@3b@|Q))k2&Yo(ggx3 z?@jXW74)rJx6VCxJNMjk&poGp9v^R_;A$HCx#P_J6!klNkgfzVa`O)=iW;I=ilv=Y zvv8-IY4WUUR>586RClSH)m@rq4UO;BPHmU2Sx4fuQ{SwIG>y~HWo$NfnVL;q=4LZ_ zr*+14#W%-yS(+{6S?5gXN^DN-N@`B(N^VXjX?kZ$S88)=S6XvgS9)_gO{pl3?ktrm z8M{MjdF)Awv4-=;H=+J`iFqr9CuPWGzex+v%{hSI$ePZZ<&<1Dj>}`?xdm++*7CTf zc_Ev?Er91dcus_8#z+Z&>@qe9-Xy~>gVe=_0g+&3IhfoX=)*CN_)HaSO7b zNP<_#W&@tZTyEzkWvzrbo5RJQ&z0K17SGHNb;x^M`=*$!xnLd~3+{qrp#sMez_ERf zdN5Ltu!|n2$#v|ACaWrJU~OK`>*(Ub1`K=MF3w7a4coiD4!6tZw5r4U^)`=p z)%}~o`Ukm3eVoS|j^DU*=l$#U+c^RRNRsY%+8nN(++B{rM zwUEZSW~5o{HmAMC<7ju;ygr`mEi5RqdF)twtA}}{pvcMXUm(a z3K$KmWA&_og{jY)**G?SMB7d~;IB{XRg0CQIyF)(cK2&p%lQPU&O4|J>K@vf=(`0F zCI+snmtKrK^>XBu$EOCLxc=-Lkqd*r|L{ffri8)%0)WfcUjJ_7;uDdef`N}fb@F* z2iHD689Dw=!s+>zeLZx8+~C6`U=9xOHW@re0l2dGgB|T8GY zpBjz!AM)*l9M_I~eC@4^kam6OgQ@f9uU_g01lNb&jSL=(UU)TfWHfT-qubF42%uTf z^WVAl$up57??m4jg*IRN=zQeF$=;FU|* z>)hSTc0O3Sx5oBx+p6jchrRZJvhsU-_P4Zkc^f;sx%RNCW4Tq!r$C2<%`E4!^A5sy z!fGGy4C|m$c-MFjbaU(9pu=&T%L81Y1z3W^0bE7h&UwR{PLJCa*0R2?ZjV*Rry?#b zFf$lSlLtq#1^GqT>>xuaJm`HemMso8tm|0b(&cIQ=pYx8KEvG7cLDX5b{_~Qbl6<1 zlWS@9c^ocaG(4{0Y4Gzr4Z$_)PyN)ig^Eu)vGM4}!H0)igK>-c*M*YOhB8lP4lg^$ z2a}feZw@7=4;7s(8g3X(4kj1(ZwXnFPjnpZ7~Fp@IcT}3f5UV(WlRXgrGJ^XHMICZ zC}+d8idx92Lb=Qhvo0xlT5ZU(Osh>vrfGGYMc2Rn>s%@>@kIU6`oXr*d5zUJNwJXOi3v`|~*2RC;@>JTja^Y9k>O)*y=9pfK?ZZdbJly?~O-DFV=%xo!Hy6F2AbNI4cPrS)GJWx_6rI z1l6bZLRrGqX^~>FyI(Eg>COB8e}#0It()b-I-mw}7oPbgfCSb`DBlw{Ie@Y~ zUYpB~E1|>g4I5)izm_i}pxwafJYl_9X<>e;-L0J-98WT3cw~X$d|cn5q?XPeZ;J@s z(&O-UxP9K1eH`y-JHW36zy|nvz5@YFAA?Do97;?ZGM_XLuL&kH1KKNQ%Yg6c`r)Lf z?hU1+4QvV}XAILPiw5)|L&9LyNY!x9nTFw#(M99g%f?fR#|^~;`YQ=(lL>|635BEj ziK{bf&ulFbmm;jE9^x2vH?%P zP&Jke!Jn>}Q@^5Us82%DQ^sjE#BO==pqhu7)mhYcS5-mySq6<^mU(rd`m-f8gkhu2 z=j|YKE^O@C-|7bG2H1)QxoueEarA<;)aGz{f!pk}IeoCA@PeQP%*U>r26`xoh#PBR zv;}d4rTcV#+OP4e{2)Gzs93dM>!4Z9h^9~P$0?{q0RWy1UUA}5RHwLa0BrJwBEor- zMA+5TV>D|%2!!>5YDoQrR!j96{l-pta?05#zs|4s8_vhQi9`t3W0aUrE#@=%O`TZp zS(hZi(iFU(V^T}+?J5vk;7_XAWALo37Y$RamOAzT{A%H6TufC^1P1283#1OUl$P?) zJt~^o0|FXN9WV1V7H-wUxw=3TU^^k>R{t7eyDU`74Pt$6Qkwg;Qz&C&O?1Fk9u2{{`@6fBY zx_8wOdoOdfS^dKVdbM8n!^FiHuZn}@AKpu^HtIg3bP)ecPeb}=MjGR0-ReC3XG!$x z)VR-*^%&36LwZ;*5>Z$OWP%Jx$tns2ZuHEcXeYEpTfxTMXxSPLuf z2JhTMP}fCLX205-FU6%A^l9(f8hLd4ba&~|G^x%sbr;BVzpm3N)g^XkGh*$U2O{&X z5LtJH$i6E?P7LBranZkPn{pLYu{72uDdyL)dF+Do3#G+b8UY&R`?-5VR)&H2FgJvv zC4Gi@U=(cu!C`JZiazTz%>%P2)+c>tzy8o_znQxj;Q)9cNr-Qn|FShG(LR^P{wk(jD$JnP@+E(1)o9Hu};`~ERbMf?~yfwYUXNj zVyu;Ws6qPJucST7EKG}MU@DNxh~3$w7^WFKFahw5Hg;zVV(lb7rjptOBIh4w^;*u1Hq3=hR_MY(Kw)1z z!I&XooQ18ZFG)eab73ov)lJbCX0{~O7yhKfD%dC_{c}3` z$dR{2eI8MLfsX3x$DhD171d3kxH@$)dh8IBUzX2Ao;?Ql9uDL}ZV|Ty86Kp*!D(FK6UNFYp~(RW#Xx%mRVV5aj~-{1mzOh<3qw=qvEtB;j<2DBTlB%OQPgB}>@C{F>~ia{_U4Q%@7^A961dths#8dcr^OL`_Yl(puiQzBs%Z{ob z8;rxnh$uLgbf6Da2x7OAP1l@NEQ-AMeW4&L^r#QTcd_oT9lrMZ0HlEu8BZrRv5f^D z=s7_)#m`;?bx2p=-qW<0m3#K8ASMDCg%PccZZk*Chd z^x7LQP7HyntT|yFoX51omQ~+l<6RC{JC9orz8M3YF`(G;f*P2|1DLP|%K$yD7nM+` zOd=af9$5~*5`%jYN)0)1chl{(**X3`Oh@G(kL!F`OQ<=VfTOyWXsFg9dGomF@r4*9 zVu0%iOk(Neizs_}6!fjh{68T;3j$bpTTct}k(Sm2UJf*9c1L#yD0e{L20L*d=y}4) z@)T&nNdX&SUQn7DacfQJhDTwQ$HC)^5Nn)77I^XnT5geI$dlafI4B0(5VwHxEgVl` zQUPI$%zaw$MVRJ1xF8EVW@RYuo?ArSw!r8i51C*5laQwiex3^;TnM}8ILct@|K}?P z%L&U-OCV#{zZ)J7852&`{L+{uC1zEilYSXKoLtG?Dz zTJw`z4s8h}um7|+@Zhe%j$MHb4+qxo4kYfG&@=;LL-M3PcU+%4ykkPYXj-K+K0;qf z%^cc%a`SL+Fm>s`x+@7;!v(LTP9!WD{l=w&@n!d3$z#r?ojW+bs4AFOJ(<@qp4V_` z=ckpyy!DfLTgUUZ2J@N*P1A9D?K=8OUfGywq5+b&1q|8K$r|l0`U-61dyn=8vWtTW zB?FqTJ*`XDZl*)f>WwEi4nGo1 zwGJ3VX<0+NPwpPxJ6ah`D;+R}(zAy;PIe6YM;{EPmk*djiK#<|lZN4pbNRuiRcAOQGU8c>Bk=iPVPvZPPl9b}M}) zYuV_wiEnHPW^Em?Ovh1i*#S+~^g$JszVsFxgCu5sMX6xquf`>xXgb;y$k{U)*F3Go zq`!P^q2kiMqO>raSAlZlj>d)J_CqC&CZMi_Tp+gX6PC3B!`f*x!u;jyR5(?cG%p-C zF9f7G;6koIMopl0Q=n#ZAZ^QpVJo52K=sZE%R>RfLr@k#{^jfYXetisLTfD{L%gU( z-i#O~4VmMH%s|%0Ny8>oC~u~3O`E8U%0Ckz6?9GvX5EBgeJFj!(`Lk4|Cg`xNdr{a z4Kbj~2}6}ISFrk@FA5m8`~~5+(tq{*PnfocSEX%VNdGW{-a>1Bn7L{JJbYHWDs78~ z`f+^X7Ay7R1!-F|sUKIZUIuS|x^Q*gwp8kWG+LT8)X&UxlS=cmI1R=VG2_q55}TG$ zKf6~A@ty?A?u`rk6N5dXJaOut2!!GXO6 z`5KxZwAh_Ck7tJN3bjs;!S5z&I|bcUOeK!0a%uM^@8m?)H&HNah`g-weGf7dc{w_A z^y=j^AcIbQ^cEaPU;X&$DLC1M6XhpPN6)=4h`+FOLOPs5gLFbNF$|o&)HC9#Dz_iR z11CogV99GZdj{1!I+vn^I{%JtLAA?{_H&j3)bz6_M!PH%@D9^?!A z7*rU?SDF$41t2fc%`s++<(VP_Fmx%a+7@uv_7ev~jCaRROudk1IV%fA5W{|9~^ zlu`PrFXK`N*?>7GpvmE%hm>A(8Oyc$+RI=g=AOI9>j6yC(<-Tbt|Oo~VUvOvOOL2T zdk>ZGn~)&TCOM=;p<1+>co8;w(czB;X*lxAySOQowsXf|e*t^P=rhko&%P>1tMD2| zXX@13f+Q|cD9FtyGZB?O#8FZs@!N;VMB3VdZRLjnY#M$Z6jmh>Bqi%gO2)AMY{Kb; zU`o*qtvXJJT6o=`Jvj9bXXPNBie~{O;#t6qK?5jZ&=7uz0y8O<4|m+8{lcLSH0RpI z?_7WR2iL#-9kTI@o!=@BWqk#?kgH)4>$amL4D-Fqxzf~tH^ymmwKwMXc9g7X%EvS~ zFj)x&G<3n~4g(u%JZNdmC+D~MOj+y|+9x#T?{jcH-5_%3GvWzNV}6f=^>#F}9G(%C zkcVQ}V>!HF(p2IBODV3gLPA8$nN})W(oiO(VGTUq15OSLWSDv$_yKc}#2Kcfq_w@C zSzO*)0X8DvkQ41To~>v2_EuX_MQt%tSzXLjtte*7OKa<_Goeaa$@!BAT)BKXK+8dS zX@vqD_Y6=Lx16ixT4PwcKx&4<7O$||s%&ksbQsPq8-D=a)Rfznx7lucQ&I`0`ZG%&`k~UkH!wD_OzaOx#2kKyUtjRBC z^0ztcyxZe$^D=vE8#xCgc@B8ITvv(DQOuOUOmlK2LP{~SgKKwl%tM=snFrl)qUZ)# z*ol?E$)Q8;tX7+SZ#(byx!8Jf2w@Shu}CKEc!ynUQAK5WdG~&%j)Y(rS6%@B#mwRg zuF6(tBXKg`RprIY>xwI?>Wa&)Om%sIwRnD%kX90`)m2q>b_Ld&+T!Ik z#bAhpSgV#Ru?n5n+Aen;oRXGwaE|s4pcX*6uR|KhZX3&zVXCZymQ++jPz`MoUJJto z3$6?FIWCS@4BCES?jW`r8J=O}(PZGv)h!jmI>4^N27}hlJ6I{P-PR4cp}?7C!7P!B zQH+5|4>*bBMoH780xIosqsByNHv)-sXclYn+%cUzZV|ArYcfv5n2%&Vjl&&ap_K-Mjt(;RXQy$7zr`x_) z$}Sa$G&G|rVigzLTiNAYg#@lFtxBq#Kx{bSZ9Hc~rrhm|F;&8;3GEh0SZ*JFfw@-K z6*CnzRS?txk1a2)iVcdApafv#tpHkGNjPx~az@|I;6frllPO8jyHYoli$o&TG)4qVE-;xChX}+{V>M@a3@a`U9|1yz~`A%j% zaB>jd!K|PJn&Rtn;T&w^@%Ifz-~`w!;sQ}>1M_6qm&mo25wgo(_js9-TXk|EzJA3u73tx%A+w*6l`< zNnWZenFwIdLijT9d@0?@c>$`#(`~a8=3J^E86^!P?bvw5f>gDf<=TryJ}8artml$S zt31?7QX_G+^kz!S>pTnx#xKQ;8>;|%{#m3ZbX=(ibbl@uB#KxU#+FTHzS=FWsj8`I ztAK^Fx~;l~n@K_f=6a-neH_q|T&sE^iZryR2w1*Vrj`!qQ@H}d927=d$@ykX=$Xt? z$&TcX771F|eaym+i$FO8%uX6FB?g(H!Kf=m8TVeX*JofZML7zZIRg~a0kacXnoLaCGhx@`O9WoLP%Vsg$}kgCOs3Vn267Y70tQ-rz21)#PDy4@;w zNU6YZkk8D`VI(3!gbih)%-(=|P}oN_pd?E646I`xLv$dG`LeVF`wbNC8-U1>8_DlM zx)5K%%Z|z!8wAld^~4wyQ7PmHpl&HItls%61zT1Taz3-UIc(;R2}g=x)_(oeTf)u? z%+z>eA+y5;JS6@8^_WZ6(AB#1QD zAT7a+gKf;0q4F8%YENT+bv{Fm1UlSK5bGQBr@lXY?cH~08S=rQB`O?ZnODNjNnF`y zRYNxLoLvoJ{l_}UAhU{=l|{A7%PT9&msi!*RoPa+EU?$qvdddJJImF!RkxPg@`art z8BN7VLxbdjgi(SwQr!ro&?{(lI;(2rZLE3-kq;~vOi>&2%N1n-&axRu=P3e3y*n3y z5ONV!cPL{KI3+QouGZMm~?+vku`s%Kx@K2xS>qQ z7VD`71s)r?Kpp7i2qtkl$xDO+w+Kc^7Yh(=xs{|Wcy7!tZQkbL5&#uFF$lUv&>jjk z7LE%66KV#J3`D=%59u+YWXQ?uYr@olnIw~$_yXopu$sFcZv&G6LW_aje!aP`ttcv1}qn$NxkM!0a~E_6h`sSl9xqP9X&W0^t$U4!5$ zA`EPs&m}NDP_JwSyLOO5iqO6t?!^odhh-KFS{q*%?1oFh;C7vp!|3V*o7kd4iKq&# zr69lATAgr!-N=Yoq*ZF(+%ZzRV}9V)9xo%V(v8f#=mdyDd07*lOUVdq1sL zaYco=_TC1DEnXwo_(Bu#iShdxsYjT5nL4`w))N6T7gWU<7QSIk84ZuB-qsso&y9~G2`+%ov;a# zg%d)>HZ((K7MZ!2sp;CZ8rWcVX;~lWb zF!N$T)?Cjt`MO%M@8%;^gpyF;xRxvGA#B6%h5@0J7^-3@`XoyCDx_qFQOqkZ?*l=* zFnZy;U=gq6wMa@b!J;&Yqz*?m(A~Bko5Rbrab9~zQDGUF6V2AtmXRSVELIE*XlFR^ z909#xA)byD#yXo}P#yw9^m>p)3q?XN*$KMXLI6hHnbQ8fkm*xqMH#A|*sdr@OC$`d zOci~%H3UgnZV9xi6c65u6ks@G7U2t!oM$v+-hq@R`JE<~K%{p^F>~e=Dp!<3X-FBS zkKw@K;6GTqIZ*?%h&**xiCXSE<=mHw?qk3s!QR0XfhC5rckbS>7|Xkj>0sa79l@;X zizgbyN=8wAvC;q2Vpxxbz{WP$Fw;>W^l$fkTa%68@hAIBq3}HM9w&iL(LQ^ zz+*XP?|6g-kZ0vu{?l~#=KS9I7$O!VHf`N`fiei}3z0@bjR}8@NT9I@NI+;T$-7 z{sog?4bU?@ebJEaG5Fmag#+r_?I7m#;8A?N;7#$AcrItaHvuhP;2SJLfoBReCq{Mb z8wGz8MwM6g@tFsshSf?I91=$G^u1dyaE`n!m*%dy40DvFy=$A{8w|6{x>GOir#)b+ z&YIA{6FiMo;CF;Z&g8;xIh>D|upOqsUjWCIT;L80&Y<8Ds`cy6TO?l+uxd#leo&Hs zg5Iy6b7WGa-V@4i2iH$FbskvR`^IlvOPw&;Rqf#Q{H@(U0qFLL1`x9@HeJF#&-;wK zyw8;03$=61Of?$C@=zZ>Y1L%^d-cZm{Y<33m8OuYqcCpp|{a z=k(S}RE)ntDqk8Q@Ck^GT@st@H=q}Tas=6gKEpp7V)prV7<;T!kMQMsn22jECk_1sSNNP1;YxiC#(jWUaOJchaki# z*n@Ct(9#eaWa3iF--mA>#b7@K;L(cA_s$)HpOx|WoL>G31bqpDFm0dPn>7n|MYDZb zTS({dXqEPVq4PXHfIzU^GEqsXgBymnoZLc8H&e@li4~KHwd0Ak!Ne8)o313L-03Yi z*!P?`c=X_K&AEzTLP5~Hq<<}X-Ajp;!GtA2b0H+Aq!0EC9XxsPT;1pc!HlwCN_qd* zD^T>q#P2m{v@4j_5KL;EOj61+Zy6H?RF&zzUhnGUlz}$2+xqsuAmgG=U z#!&Xj?BNZ;qvd?NyYlqv;#D}tT&K8_5IA=Ih7Rt># zyYBS5bJk#P@h@{L$5fL!wc|OpmyE%j)uH^wuNS;r5Gbk(=C8PE)W%!BQX{@(DE&rO zJlYn;Yjx8(P^UkGaVlDps<8T^O=w!_yXvu3@8~8BwL&Xvc7j=MVCTbu>fIBTJpscW zVwBlT|MjaxK>KIUCK$rQYw|ZF(?2RmT^~>VXa&9Atoc#B2IDK2Li&%3R^@GoH~d7a zgNL7(X?XFIc;g0>`X}jS8?@@5YPAsmsmTCudvoWM2t+Y^hB$x*YD65cfYsAKN*wUh zuz1N!F{@@Z=jFv3oMvU#82gY2d4SPVEPCbI0?_SeCg&8ThOmz)+J%)b7HNS7vpULh>K?R@ad7e)y434uZ!V!VTja4 zJ5Sw>a?k1f8eR(`hq@Q&3G$f0eN^ACX3c#DS28TC`n}*p!>idic*bQmelM&*@N9u+ z6)t^#!{23;(O+lQC5;6LMOwZ}Q3}*(_T|^!nVQZgN(*}D9ZJIXLx0?@EY07oEWKBD z*!JtaIHSoW)s;{1E|k)+?|Wfpf$JuW0A8fFIyRY28PTz+@TCy=(uZjNndWN)03vjV zTm6_I0LNj%L4){7s%MWy&RzybGx#z_^w350M>=~Md6VR~86GxzmK6J+hd++G;17KsW^7ir$Nbs$P z0KDVGt@drLMTfkpbBBaR!?!>pgKvs{TLe?I|8=1!;Pl0+3+tf~Ek3V3tcP}yNHRnm zARK@PRf}-ue)g zvIKY>9&6$(Sw;|J)II;lN~MriBmL4uZc z+mhjW&RoKfxW4h9L56Ig4bS<%RUG=2IaL%!z`+PydbVASFB>xk4tsEu;^a!zsi2Ud$c4C!k3W>9Yd* zMWLi+M|B}%&hVqZG&1nz65)aUr7=I0RCZMNWl|Q%PC5#$q;rNEPBy$^cs=grxL``j zWJ>LLN^LM@MJPG*w>x&7+CQ05GM-T~YJa!mJ?GobiHtRq8QaD)woPPg4}9~#{pzj| zq5SGA8y*Ne$WF95CN^}wq75vmyHq{C|OOtfV(9q>(ePGqie*thP zPEb}y?tiEDgpAn(8I#8BabxyyUC?+>zwWoX_&{pKgs!q*J*~Tk zt~{rirrawsz^l)m^zd>)+>N`UX#!Tn3P zYvB&%n=R9Nj2SRy#F&XnNuM@jEDp9g)0htgeVyrM0>Oa4lE8VDC^G*(&aZ)I(o%$&ol+MF~g;lx8uLU=uLpE0QW(9 z1&IDao`5MgP_hy;-Fri&p;w%%ou=S6w(-)Ax3_(TQOtB7J&iA+UXUx}P{u%3kWRUw zqUl_Gn>vj#Xp;CcFGy$HQ0ZyiaQid`x6%EV)Nk~Cg;500oW^(4sUmp5m@~SyKO;!5 zxS?{XX$^wZPm}0$t6r|=k|13;t+E2PonOJ!A()Ct!t`$VXfWx3fT<`*Tc=e8FgD(= PD7YcAr8h8vXczw bytes: + pad = data[-1] + if pad < 1 or pad > 32: + return data + return data[:-pad] + + +def pkcs5_padding(data: bytes) -> bytes: + amount_to_pad = BLOCK_SIZE - (len(data) % BLOCK_SIZE) + return data + bytes([amount_to_pad]) * amount_to_pad + + +def get_aes_key_and_iv(encoding_aes_key: str) -> Tuple[bytes, bytes]: + """ + encoding_aes_key 是平台给的 43 位字符串,需要先补一个 '=' 再 base64 解码。 + 得到 32 字节 key,前 16 字节为 iv。 + """ + aes_key = base64.b64decode(encoding_aes_key + "=") + if len(aes_key) != 32: + raise ValueError("encodingAESKey 无效,解码后长度不是 32 字节") + iv = aes_key[:16] + return aes_key, iv + + +def aes_decrypt_base64(cipher_b64: str, encoding_aes_key: str) -> str: + aes_key, iv = get_aes_key_and_iv(encoding_aes_key) + cipher_bytes = base64.b64decode(cipher_b64) + cipher = AES.new(aes_key, AES.MODE_CBC, iv) + decrypted = cipher.decrypt(cipher_bytes) + decrypted = pkcs5_unpadding(decrypted) + return decrypted.decode("utf-8") + + +def aes_encrypt_base64(plaintext: str, encoding_aes_key: str) -> str: + aes_key, iv = get_aes_key_and_iv(encoding_aes_key) + data = plaintext.encode("utf-8") + padded = pkcs5_padding(data) + cipher = AES.new(aes_key, AES.MODE_CBC, iv) + encrypted = cipher.encrypt(padded) + return base64.b64encode(encrypted).decode("utf-8") + + +def calc_signature(token: str, timestamp: int, skill_name: str, intent_name: str, query: str) -> str: + """ + 文档签名算法: + str = token + time.Now.Unix + skill_name + intent_name + query + signature = MD5(str) 小写 hex + 参考:https://developers.weixin.qq.com/doc/aispeech/confapi/thirdapi/thirdapi.html + """ + s = f"{token}{timestamp}{skill_name}{intent_name}{query}" + return hashlib.md5(s.encode("utf-8")).hexdigest() + diff --git a/main.py b/main.py new file mode 100644 index 0000000..597f8c4 --- /dev/null +++ b/main.py @@ -0,0 +1,577 @@ +import json +import logging +import base64 +from datetime import datetime, timezone +from typing import Optional + +import httpx +from fastapi import FastAPI, Request, HTTPException +from fastapi.responses import PlainTextResponse, HTMLResponse + +from config import APP_ID, TOKEN, ENCODING_AES_KEY +from crypto_utils import aes_decrypt_base64, aes_encrypt_base64, calc_signature + + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger(__name__) + +app = FastAPI(title="WeChat Dialog ThirdAPI Demo") + + +async def handle_business(data: dict) -> str: + """ + 业务处理钩子: + - data 为解密后的请求 JSON(XwBrainThirdApiReqInfo 扩展结构) + - 返回值为要展示给用户的短文本答案 + 后续如果要接数据库、HTTP 接口,只需要改这里的逻辑即可。 + """ + query = data.get("Query", "") + skill_name = data.get("SkillName", "") + intent_name = data.get("IntentName", "") + user_id = data.get("UserId", "") + + # 如果想返回普通文本,可以使用下面的示例文案: + # parts = [ + # f"技能:{skill_name or '未提供'}", + # f"意图:{intent_name or '未提供'}", + # f"用户ID:{user_id or '未提供'}", + # f"用户问题:{query or '空'}", + # ] + # return ";".join(parts) + + # 当前示例:返回一个 H5 富文本结构(平台会对 short_answer 做 JSON.parse) + h5_msg = { + "news": { + "articles": [ + { + "title": "实时更新:新型肺炎疫情最新动态", + "description": "腾讯新闻第一时间同步全国新型肺炎疫情动态,欢迎关注、转发", + "url": "https://news.qq.com/zt2020/page/feiyan.htm", + "picurl": "http://mmbiz.qpic.cn/mmbiz_jpg/W3gQtpV3j8D8kZRqfpTJlfVqubwgFQf47H0GWlGV6leaDF80ZpdtuFhQVsCsM3YKmwkujXzdjR2k6aWfA41ic7Q/0?wx_fmt=jpeg", + "type": "h5", + } + ] + } + } + # 按文档要求,answer/short_answer 是字符串,内部再 JSON.parse 才是对象 + return json.dumps(h5_msg, ensure_ascii=False) + + +def _decode_jwt_payload_without_verify(token: str) -> dict: + """ + 仅用于本地调试分析 JWT 的 payload(不做签名校验)。 + """ + try: + parts = token.split(".") + if len(parts) != 3: + return {} + payload = parts[1] + payload += "=" * (-len(payload) % 4) # base64url 补齐 + data = base64.urlsafe_b64decode(payload.encode("utf-8")).decode("utf-8") + obj = json.loads(data) + return obj if isinstance(obj, dict) else {} + except Exception: + return {} + + +async def query_private_messages( + authtoken: str, + wxbot_bid: str, + page: int = 0, + size: int = 30, + filter_value: int = 0, + request_id: str = "local-debug", +) -> dict: + """ + 转发请求到微信对话开放平台的私信列表接口(你在 Charles 抓包的接口)。 + """ + url = "https://chatbot.weixin.qq.com/miniopenai/manualservice/getaccessstautuslist" + body = { + "bid": wxbot_bid, + "filter": filter_value, + "order": {"page": page, "size": size}, + "base": {"requestid": request_id}, + } + headers = { + "authtoken": authtoken, + "wxbot_bid": wxbot_bid, + "content-type": "application/json", + "charset": "utf-8", + } + # 调试场景下常见抓包证书/公司代理证书导致校验失败,这里关闭 verify 以保证接口可用。 + # 生产环境建议改为 verify=True 并配置受信任证书链。 + async with httpx.AsyncClient(timeout=10, verify=False) as client: + resp = await client.post(url, json=body, headers=headers) + resp.raise_for_status() + return resp.json() + + +async def _handle_wechat_request(request: Request, app_id: Optional[str]) -> str: + """ + 公共处理逻辑:便于同时支持 "/" 和 "/wechat/thirdapi" 两种回调路径。 + """ + # 1. app_id 校验 + # 说明:有些平台配置下不会显式在 URL 带 app_id,这种情况下我们只做日志,不强制拦截,避免 400。 + if app_id is None: + logger.warning("请求中未携带 app_id(将继续处理,仅做告警)") + elif app_id != APP_ID: + logger.warning(f"app_id 不匹配,期望={APP_ID}, 实际={app_id}(将继续处理,仅做告警)") + + # 2. 获取原始 body(加密后的 base64 字符串) + raw_body_bytes = await request.body() + cipher_b64 = raw_body_bytes.decode("utf-8").strip() + if not cipher_b64: + raise HTTPException(status_code=400, detail="empty body") + + # 打印原始 body,便于对照平台文档排查(是否真的是 base64 + AES 加密数据) + logger.info(f"收到原始 body(未解密,前 500 字符):{cipher_b64[:500]}") + + # 平台当前实际发送的是 {"encrypted":"..."} 这一层 JSON,需要先解析再取字段 + if cipher_b64.startswith("{"): + try: + outer = json.loads(cipher_b64) + if isinstance(outer, dict) and "encrypted" in outer: + cipher_b64 = str(outer["encrypted"]).replace("\n", "").strip() + except Exception: + # 如果解析失败,就按原始字符串继续处理(保持兼容) + logger.warning("外层 JSON 解析失败,按原始 body 作为密文处理") + + # 3. 解密 + try: + decrypted_json_str = aes_decrypt_base64(cipher_b64, ENCODING_AES_KEY) + except Exception: + # 常见情况:非第三方服务接口的探测/噪音流量,或使用了不同的 AESKey。 + logger.error( + "AES 解密失败,可能为非本机器人第三方服务请求或密钥不匹配(忽略该请求)。" + ) + raise HTTPException(status_code=400, detail="decrypt error") + + # 4. 解析 JSON + try: + data = json.loads(decrypted_json_str) + except json.JSONDecodeError: + logger.error(f"解密结果不是合法 JSON: {decrypted_json_str}") + raise HTTPException(status_code=400, detail="invalid json in decrypted body") + + # 5. 打印请求内容到终端日志(分析、显示,带中文注释) + logger.info("收到微信第三方服务请求(已解密):") + logger.info( + "请求概览:RequestId=%s,用户ID=%s,问题=%s,技能=%s,意图=%s", + data.get("RequestId", ""), + data.get("UserId", ""), + data.get("Query", ""), + data.get("SkillName", ""), + data.get("IntentName", ""), + ) + logger.info("完整请求JSON:%s", json.dumps(data, ensure_ascii=False, indent=2)) + + # 6. (可选)签名校验 + try: + timestamp = data.get("Timestamp") + skill_name = data.get("SkillName", "") + intent_name = data.get("IntentName", "") + query = data.get("Query", "") + provided_sig = data.get("Signature", "") + + if timestamp is not None and provided_sig: + expected_sig = calc_signature(TOKEN, timestamp, skill_name, intent_name, query) + if expected_sig != provided_sig: + logger.warning( + f"签名校验失败,expected={expected_sig}, provided={provided_sig}" + ) + # 这里直接拒绝请求,你也可以根据需要改成仅告警 + raise HTTPException(status_code=400, detail="invalid signature") + except HTTPException: + raise + except Exception: + logger.exception("签名校验异常(忽略或按需处理)") + + # 7. 根据业务逻辑构造应答 + try: + answer_text = await handle_business(data) + except Exception: + logger.exception("业务处理异常,将返回兜底文案") + answer_text = "系统繁忙,请稍后再试。" + + resp_obj = { + # 文本类型,内容为可被 JSON.parse 的 H5 结构字符串 + "answer_type": "text", + "text_info": { + "short_answer": answer_text + } + } + + resp_json_str = json.dumps(resp_obj, ensure_ascii=False) + + # 8. 加密响应 JSON + try: + encrypted_resp = aes_encrypt_base64(resp_json_str, ENCODING_AES_KEY) + except Exception as e: + logger.exception("响应 AES 加密失败") + raise HTTPException(status_code=500, detail="encrypt error") from e + + # 按文档要求:如果配置了加密,这里返回的是加密后的 base64 字符串 + return encrypted_resp + + +@app.post("/", response_class=PlainTextResponse) +async def wechat_root(request: Request, app_id: Optional[str] = None): + """ + 有些配置示例直接写根路径,例如: + url: http://example.com/ + 这种情况下,微信会 POST 到 "/",所以这里也做同样处理。 + """ + return await _handle_wechat_request(request, app_id) + + +@app.post("/wechat/thirdapi", response_class=PlainTextResponse) +async def wechat_thirdapi(request: Request, app_id: Optional[str] = None): + """ + 显式的 /wechat/thirdapi 路径,同样处理。 + """ + return await _handle_wechat_request(request, app_id) + + +@app.get("/health") +def health_check(): + return {"status": "ok"} + + +@app.get("/debug/echo") +async def debug_echo(request: Request, app_id: Optional[str] = None): + """ + 调试用接口:回显请求头和 app_id,方便排查签名和路由问题。 + """ + return { + "app_id": app_id, + "headers": dict(request.headers), + } + + +@app.get("/private-message", response_class=HTMLResponse) +def private_message_page(): + """ + 私信查询页面(本地调试)。 + """ + return """ + + + + + + 私信查询 + + + +
+
+

私信查询调试台

+

用于调试微信对话开放平台私信列表接口,支持一键查询、Token 过期分析与结果可视化。

+ + + + +
+
+ + +
+
+ + +
+
+
+ + +
+
提示:查询失败时,先检查 authtoken 是否过期、wxbot_bid 是否与当前机器人一致。
+
+ +
+

等待操作...

+
{
+  "message": "点击上方按钮开始查询"
+}
+
+
+ + + + +""" + + +@app.post("/api/private-message/query") +async def api_private_message_query(payload: dict): + """ + 查询私信列表接口(调试版)。 + """ + authtoken = str(payload.get("authtoken", "")).strip() + wxbot_bid = str(payload.get("wxbot_bid", "")).strip() + page = int(payload.get("page", 0)) + size = int(payload.get("size", 30)) + filter_value = int(payload.get("filter", 0)) + request_id = str(payload.get("requestid", "local-debug")).strip() or "local-debug" + + if not authtoken or not wxbot_bid: + raise HTTPException(status_code=400, detail="authtoken 和 wxbot_bid 不能为空") + + try: + result = await query_private_messages( + authtoken=authtoken, + wxbot_bid=wxbot_bid, + page=page, + size=size, + filter_value=filter_value, + request_id=request_id, + ) + except Exception as e: + logger.error("私信查询失败:%s", str(e)) + raise HTTPException(status_code=502, detail=f"private-message query failed: {e}") + + contacts = result.get("contacts") or [] + return { + "ok": True, + "count": result.get("count"), + "contacts_preview": [ + { + "nick": c.get("nick"), + "userId": c.get("userId"), + "lastMsg": (c.get("lastMsg") or {}).get("content"), + "lastActiveTime": c.get("lastActiveTime"), + } + for c in contacts + ], + "raw": result, + } + + +@app.post("/api/private-message/analyze") +async def api_private_message_analyze(payload: dict): + """ + 分析 authtoken 的过期时间和基本信息(基于 JWT payload,不校验签名)。 + """ + authtoken = str(payload.get("authtoken", "")).strip() + if not authtoken: + raise HTTPException(status_code=400, detail="authtoken 不能为空") + + claims = _decode_jwt_payload_without_verify(authtoken) + now_ts = int(datetime.now(timezone.utc).timestamp()) + exp = int(claims.get("exp", 0)) if str(claims.get("exp", "")).isdigit() else 0 + iat = int(claims.get("iat", 0)) if str(claims.get("iat", "")).isdigit() else 0 + sign_time_ms = claims.get("signetime") + + expires_in_seconds = exp - now_ts if exp else None + if expires_in_seconds is None: + status = "无法解析过期时间" + elif expires_in_seconds <= 0: + status = "已过期" + elif expires_in_seconds < 3600: + status = "即将过期(1小时内)" + else: + status = "有效" + + report = { + "status": status, + "now_utc": datetime.now(timezone.utc).isoformat(), + "claims": claims, + "analysis": { + "iat_utc": datetime.fromtimestamp(iat, tz=timezone.utc).isoformat() if iat else None, + "exp_utc": datetime.fromtimestamp(exp, tz=timezone.utc).isoformat() if exp else None, + "expires_in_seconds": expires_in_seconds, + "expires_in_hours": round(expires_in_seconds / 3600, 2) if isinstance(expires_in_seconds, int) else None, + "signetime_ms": sign_time_ms, + "summary": "该分析基于 JWT payload 解码,不包含签名有效性校验。", + }, + } + return report + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..659d2f1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn +pycryptodome +httpx diff --git a/run.py b/run.py new file mode 100644 index 0000000..529ed34 --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +import uvicorn + + +if __name__ == "__main__": + # 如果 8000 被其他程序占用,可以改成 8001 + uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=False) \ No newline at end of file diff --git a/token_client.py b/token_client.py new file mode 100644 index 0000000..f6661e6 --- /dev/null +++ b/token_client.py @@ -0,0 +1,53 @@ +import hashlib +import json +import time +import uuid +from typing import Optional + +import httpx + +from config import APP_ID, TOKEN + + +TOKEN_URL = "https://openaiapi.weixin.qq.com/v2/token" + + +def _md5_hex(data: str) -> str: + return hashlib.md5(data.encode("utf-8")).hexdigest() + + +async def fetch_access_token(account: Optional[str] = None) -> str: + """ + 调用微信智能对话 /v2/token 接口,换取 AccessToken。 + 文档参考:https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html + """ + timestamp = int(time.time()) + nonce = uuid.uuid4().hex[:16] + + body_obj = {} + if account: + body_obj["account"] = account + body_str = json.dumps(body_obj, separators=(",", ":"), ensure_ascii=False) + + # sign = md5(Token + str(unix_timestamp) + nonce + md5(body)) + body_md5 = _md5_hex(body_str) if body_str else _md5_hex("") + sign_src = f"{TOKEN}{timestamp}{nonce}{body_md5}" + sign = _md5_hex(sign_src) + + headers = { + "X-APPID": APP_ID, + "request_id": str(uuid.uuid4()), + "timestamp": str(timestamp), + "nonce": nonce, + "sign": sign, + "content-type": "application/json", + } + + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.post(TOKEN_URL, headers=headers, content=body_str.encode("utf-8")) + resp.raise_for_status() + data = resp.json() + if data.get("code") != 0: + raise RuntimeError(f"fetch_access_token error: {data}") + return data["data"]["access_token"] +