From d2d046a2b92af007d6ec98fdb47babfec5f5cfec Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 15 Mar 2026 21:03:03 +0200 Subject: [PATCH] refactor: restructure as installable PyPI package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename app/ → fragmentapi/ for proper package naming - Add FragmentClient class with gift_premium, gift_stars, topup_ton methods - Restructure core/ → types/ (exceptions, results, constants) - Merge utils/hash.py into utils/client.py - Replace _version.py with importlib.metadata - Add input validation with min/max bounds - Add unit tests: decode, client init/cookies - Rename 002_test_hash → 003_test_hash - Clean up pyproject.toml: pin deps, production classifiers --- .env.example | 11 -- .../unicode_data/15.0.0/charmap.json.gz | Bin 0 -> 21726 bytes app/core/__init__.py | 44 ----- app/core/config.py | 46 ------ app/core/cookies.py | 32 ---- app/core/exceptions.py | 42 ----- app/core/logging.py | 20 --- app/methods/__init__.py | 5 - app/methods/premium.py | 151 ------------------ app/methods/stars.py | 133 --------------- app/methods/ton.py | 132 --------------- app/utils/__init__.py | 14 -- app/utils/client.py | 39 ----- app/utils/decoder.py | 36 ----- app/utils/hash.py | 57 ------- app/utils/wallet.py | 107 ------------- cookies.example.json | 6 - fragmentapi/__init__.py | 44 +++++ fragmentapi/client.py | 112 +++++++++++++ fragmentapi/methods/__init__.py | 5 + fragmentapi/methods/premium.py | 119 ++++++++++++++ fragmentapi/methods/stars.py | 103 ++++++++++++ fragmentapi/methods/ton.py | 108 +++++++++++++ fragmentapi/types/__init__.py | 59 +++++++ {app/core => fragmentapi/types}/constants.py | 5 +- fragmentapi/types/exceptions.py | 106 ++++++++++++ fragmentapi/types/results.py | 34 ++++ fragmentapi/utils/__init__.py | 16 ++ fragmentapi/utils/client.py | 107 +++++++++++++ fragmentapi/utils/decoder.py | 20 +++ fragmentapi/utils/wallet.py | 69 ++++++++ main.py | 66 -------- pyproject.toml | 40 +++++ requirements.txt | 2 - tests/001_test_decode.py | 19 ++- tests/002_test_client.py | 81 ++++++++++ tests/{002_test_hash.py => 003_test_hash.py} | 4 +- tests/conftest.py | 16 +- 38 files changed, 1051 insertions(+), 959 deletions(-) delete mode 100644 .env.example create mode 100644 .hypothesis/unicode_data/15.0.0/charmap.json.gz delete mode 100644 app/core/__init__.py delete mode 100644 app/core/config.py delete mode 100644 app/core/cookies.py delete mode 100644 app/core/exceptions.py delete mode 100644 app/core/logging.py delete mode 100644 app/methods/__init__.py delete mode 100644 app/methods/premium.py delete mode 100644 app/methods/stars.py delete mode 100644 app/methods/ton.py delete mode 100644 app/utils/__init__.py delete mode 100644 app/utils/client.py delete mode 100644 app/utils/decoder.py delete mode 100644 app/utils/hash.py delete mode 100644 app/utils/wallet.py delete mode 100644 cookies.example.json create mode 100644 fragmentapi/__init__.py create mode 100644 fragmentapi/client.py create mode 100644 fragmentapi/methods/__init__.py create mode 100644 fragmentapi/methods/premium.py create mode 100644 fragmentapi/methods/stars.py create mode 100644 fragmentapi/methods/ton.py create mode 100644 fragmentapi/types/__init__.py rename {app/core => fragmentapi/types}/constants.py (90%) create mode 100644 fragmentapi/types/exceptions.py create mode 100644 fragmentapi/types/results.py create mode 100644 fragmentapi/utils/__init__.py create mode 100644 fragmentapi/utils/client.py create mode 100644 fragmentapi/utils/decoder.py create mode 100644 fragmentapi/utils/wallet.py delete mode 100644 main.py create mode 100644 tests/002_test_client.py rename tests/{002_test_hash.py => 003_test_hash.py} (82%) diff --git a/.env.example b/.env.example deleted file mode 100644 index 806b019..0000000 --- a/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -# Fragment.com cookies - copy from browser after login (Header String format) -# Hash is now fetched dynamically - -# TON wallet seed phrase - 12 or 24 words separated by spaces -SEED = "your_ton_wallet_seed_phrase_here" - -# TON API key - get from https://tonconsole.com -API_KEY = "your_ton_api_key_here" - -# TON wallet contract version: V4R2 or V5R1 (default: V5R1) -WALLET_VERSION = "V5R1" diff --git a/.hypothesis/unicode_data/15.0.0/charmap.json.gz b/.hypothesis/unicode_data/15.0.0/charmap.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..c740c027cc85b3221abcca5382b71ad927e89785 GIT binary patch literal 21726 zcmbT7b97|g*XLs!9oy;Hwr$(!*iLuYvCWQ+j&0kn*h$64yK6sv^l*eL)QRA@GDv#IAFm% zE&IPyLH+WC+1~%`=K-(b{3Ej|Uq%s8xwB9v__o8@{XXyD|9*fR@Z|q)-XOTuxwN-z zxW*IkW-8tNe35(Y6zLQ@)Wf;7a-Td3tl^hkLszMMdEOKmt0`=!FKeC;ct5b8l?6Hl zXs#!?>pq_`Hqz6)dMJ7{3tLYlgbIw)ow&-B2;dRDY*Y-lZmNlTxs61A+U1^Kj4etL zf_iPs#d}>EM)E~}Rz;uR1IrHtZAz-tB^B0Nez|;}d&Jh>$G3?#Z$5Uk#Rl~I$?5s- zo;p_Ztq8gmAbkR#6)tX{T96Oz9WQ5rv9_|-%bf(7b3Yy>?cDuccFTb&D*hA{pS?D2 z?-kb`6{5JZ)3$qFb~0R|rwCN#sXi4~cU@k&Gn)!B_ZXM%*WAq-)9H`3&Ckhf^oUaD zkdO7tR(nr9z?iPz0(d~hTsO(D6_$X94X!deL`AsejYUa|N1HNzmZN7`CDlJpea|W! zs%vC*;ygn`0t$VWG;07=0_1j5FNdYfU7-THZsxYPV^_tloiu)sF9@=rzb#iwG$+OsVJR3Su9(!qi_CxD`2}? zRb#~K-b;>V{94b_4cZp|L}9Tp;l$U@NPq8K@Dg8buAzarF16`uvXO#7>eQt`v8%ax zGwz8ioHH%ob_l)JDg;gkrm+R=1-EVTN^HUr{jk<%%H2fXzXow}~H z8~tDD)3~QlK<+Y5@#W=)8-3#6-8A+vgvlAxMLuRWU`)t&-ANS@liZ5KzwOCvr#jyN zlXX`_h4~xqLc9FBEz6O>el0w|(@Tbp&7#-V>-I_YLxyHI`h1^n?7$@r6(N({*)xxP zCxUy9r4+OwZO1BHW!C=9@>K5%oA0olh2GpxQMQXc2=2|ID%m8^pZ&dm`P-&cZTGlv zwU4%0W8$6{&oy*q?h8w{TAt~Bhh|yx%@;N1I?-JmQ`+ppf^E~=KSb>)?4*RtB+Tq6 zAj$nh7kBMfWw&)imVf!UH=nbjKAj)`bs@ecGLN2K``Tg8u1ZTaU742C1$C-w z9=F9bL7=;8`KkqB4zL{Iaf)%$NK?9HxxVmmgM(y8=d#Z~pxHGW{z#Xnzi-07S-MPO zljp7ZY~ih*ZN6Idv9f8#p!+4DF_}$vH+-WdrOLK-OoZ{b!;?LD8wVh46AZAAz0tW; zS3!H_Q6TZWsggOPM2pojvL z!zx)i;Fl>!{XBln4PjllqaNSOk|7I^{HdU%7Tz2p9d5?#Gvllz{q6y(XBU3l-qLT9 ztEXiPDJm!YjYVp=tDAG7z1dP#nX6zb#~%0iHnJx|yYC$lm~?z=zaE)?XgR%YVpou_ zk!x=9P+YGbO8E|zq@!yVE2qK_E$-qd)cE+B!R8oP3ii9}7icfPBr|XU%9ct?RF&_?R*DHS^q|ygS&*0~2esmUniB>MB{s7w;9uyX>GDcKn`4qyP3^ zIg$={?5{!Yy4-y;Wmk6#?N)XWu%-Zb>+cGPCHy(vh}%>Vy!;9DcCmCQ;?qD+u)WS| zqB!DLrbjoARgU=$IcW=*yl)eu*@BebB!1xtx?unc{!S1BXn#qLSDQ@eXcfEp*pRL_ z=v+te+p5FX=Ir3juK$=;Z@GH!y>|DgJ=dF*%~j0m#vT1Fu9b2@M^R>WBI7~zZGYkU zG6GEd`@st0`$h3b{?>-Y@tZQ-lbwm|(0k`(l3tA_`zFg2aM7Lob-u&LqxKD{rkl+6 zO|2iOC}*=sH`*eox(6(GY+Tt4%>`uR8aAivJ4ct;a7`;Rgw3BBBgQ4X&K(h`el~EP z+SELCv7PB0G_0_X3v<&EjTzw8ZEKi%DK9-!eyikuEJi4*!mUcP+IiyD^|R8jzT zgnGcF;`(j9GKuamQOIcXmy@=s?y&>S=xPu7yPD_S%LUJ`T~!iLJbaAp>c)FX%-z2< z+Ufq=R858UY51ajK{xi|C(_x9n7hmao=ap%v~X@$Rpkz4W=-%~@$4)}Z~LeMUmkBi zKb~HjyVYo1tDn<_tgmz=_IiuVCPj53)mFa z%qBn9_F0#QJM8>_z+v{BO0od8grWk~=*l6Mt?qR``V1R3yPh?ir2F^KBg5$ZDWFF{fHa~* z3f4iAozm(3t{xE(u-SW0XZU&gw9zEpciy=cv6Z^-n=pMUHStK}u%oc$FpP7Fv=muze|{-9 zK+$$yR?+1jccNXMExigDzRyAR&9U75Ac_UDa++_}@G9;+%03?H;2j&j^!NdJbJ?qF;if4di*M|8R}D>rv2D2)0N%pf^>aeRBHoLjy(G6PUYVhxYukZT;F2fr`FW zheT`%vfW*XfRgsV&(BLoozy|BEL~W!o8n0dN!yhTg=8w&e{4Q?e(yQHoHvz!o+Rk&|5xhU( zGvMY^@zb#KsG0ke=uDD*lI3OjMW46jqw~&p`%Z++Yv(fzh`bcJ{$uyzU&VBrmSa>+eNKtnziVZOs!poO;x zIryT{3Pxo^e16&I)UOrsXuX}4W}3=jtZhpa`d5ccqYHI=chCp&muJQSdcZ%oe#ibq zJa>DJ5Db@&VX}YxQkV_w$zsJTzRp^rWZ*9bgm-{~q&QI|U)N0?&+OdIp zPKh_{CSLEB9(Oe8=IV&OJLIDEpR_+ba=rpy@Xv^F$205s_+ez#3O-QN!kPWLMnCwr zs&m55k_8A{9W)C*uxc#Nyfz4Y4PSofz1sCPlIJd5bWK?jo%+$s0z^!4rb-r`3=CKC zP;VH;%uddlXI~S-NS;{_x_`2Ma3%O)Z?_{Z_s<&(5=tIjDohGCOF!E<@>`KFdzKw~ zInW|)H5x1-`3JPU@HT#Ale`{ZvW;aa0u?%mtjHDpryk$G4wLrrlIj4X&aO3D;BxDO zrT%A9^ZDh%nm$`omMD4nij>8( zI5zHjq}tNScX??rQ+JRiadEH{f6IhP)J=BL_@HwW{;o;()2+oOx8|x`AyIO}h2aC< z*7#uX&pQ1D6>V4wf$p&6?o~K&rxRAp)zxQxDvx*iWo|Q3^oq`K=cjC$4u7wEBgI39 z-|*#KFmJ$?AitOE{uA8v;p(&jQ0g>%+RanbVI6?_PTLe)*p=8th%NmC5MM>6>(4KS zxbh-3v-mjp7(++k)h$Di-(B`_)je|xV}kV6XD0qCzSnTHIjXaW49Kf0Sv<{-J~ce8 zc+{mnYP;k+=bh;)wH2aEIArT|HF6_p7pXAW)jp-*D%$vZi)v)lX8C7Lmf(7|MrF#7 zV8`Oppn&(T;W;Karo-^Qm+YOO7h$H8))@JH#X-x*b8HcbsH_aRs@hR#Rh)M#Uf$#U z%=K}xE9?9=n{hl+p^zP(a{NTwMqfr4Sl7IBX zw+<1cRK)hmYsXG_?bk&qc@r*a(XdeSrdYE!wJj9@Gd23xuoFnI6A*jwJ!-I-x5Iz4 zvkLe_QvVwu$@4D+fxfwcyrjvmN2-3y?zZ)v`1_cvbc>dw1-c9ofz8f-ZT{HYso6Ex z!bM}?Ket)7J4dXQU$>91i{h*_x_E~fGTZFY}@ORcGEE9CYP;E zE*NryL9J!qN2Y1Rc`*l7wFRhc1M5e6u*)Tz`;D62SX9iR{gi{qIwxJfqiVOFj~tpB zXByd-uwmx6dQID=vJ}$-!-FOZ@;u9<{&_V1ibXiCwHUIcXV%ql;J;vaU@2Je#N6K5@qZwLy^ha`&uvqupE6Y6vE z2LgjLG6N4_y`ZLB?Q}JwP(Z))3>kjrUKRMT$Tz zsFL(MNloEQp&hektU(%K=b`d4Q0+h(V0$%Q`zA3@dj-XZwnO`)#ONhTZ?*fwc*+dH>$#y$PQ1hG%enQ@uqOYBW#Ysh+dYw)x zLHFvj$g1yKTydzLe;q$ni)|jJDDwfn|5GY6l(s4=P90d&VNqg~Hz57hxAW_&3Dls_u3LR93mv$d#C`(3xzcwfq5i}j#CZ{$-tNQUAp|4K!6 zWXNwVpEk=?Ixs_|5;DN2>S2Qk^R}_Z%mCF2?XQC?31JIYU3T}h3rxyg@EA)SvE>bP z8m-VURe`}T+nETa04(aRkFtC$ZUo5yXN3@&- zHGHG)Qaacdy;5n}gcK{6DhT!9pBN+V^SCn&vs_qtB`mEyOMzT!qspp_IvN7u->X<=uV$`e%6FKR?||EfZzUME+5NB?c{gEg;00kkakJfz!Hp~c&?xJUHED&cpiK z24W;2kxQ&T2s1|X+6Q|+Dl@`x!GGe7B*PrYY<%m4(M2H=afz4!r^b&NhnhLcr*Jbt#mg8lXEz&3nhU}bwj1$tqF3o~`|H}I{AXMvA1#E^sx9USBkV!y*D&f7IH0;6 z4Jh_@i#6TdA8K!6DZ&U;vRm$?XMBV}-VXx2&L8)`wr!~&-~d{76!}=u!cV?7vNX6s z^$9bwrKv)UV-zqwM5GJwJv8|%4E1Cp@d+StocO6>k~B%W=pf9C8ff0LE0Eiq`(ADI z|5%>&|D<_{A+VQ3PmLH2du*olBCrsfb;#c4oHTL6vw59R>J-wHlieMbUMgB2B(|R` zXkbM?dUIe!eS$d-mv9UL86+uScN4q4tPZ|5vCjP`{;Vg?eo(^80JdCo@BblexM01< zYce8?xIw>}({O`Eq!IHc>>~}>pwLa3*r8xS9N&npDwQ5=h=J|@x@Kg805|NWd-sPq z4LfMerSr>eY`G}mJ$NIT;2*yOnNjO0!Jg|HlYL!&4(Klo)@xf*(}bOA&5F&Q$>FF=NY}{0L7jRWfu7W3VL@cKU_R3 zf;IRD{fn;ZgK{5~jMonOc6t(3iqh;`lYeu3EJgxa=ZHpdiNHaLV&#NH6lML9N{SlC zJ~ItR8p!m9T`C(1y*ZVziUf*Lu|zs$#|O>#eFZQs@OQQ5!Bz8Fu&pGKc?x`vGAok& zKQ#0tI|ya;tZ|O1-<0cMsiel>6NFF-eTiT}+{c3Aw^5{=V91pSpP2}ymh6VAG1q=U z?KFu?soGK-EJ5vnTZ9rXAx4`&Skm>vq(?X5S!+dmeC$8ot|qey+hHN%O7y49=2^2% zy>=le>;4H2pmu0+w~FF4VZjqe^}o5*I@RrS+mJy zTQ9kIknZ&N;Ub?n&TmK3HsQ)0&ot9eE0HIh*2CS z1R}luwKQ_>P$2 zeRsvEVkS0-c3$OpTfKo=n#$v&V;lp%{##4Bi6QHV8De>bf5bXx+%T-|b`0qK=zFJl zm3o?YygY{rqm6?-U%K(R&t#;rK(vbt5tY| zZfZNIE4n8)`Zja%llpJ+&p)7FJ?u%H1^=0%{~hsxa|2-XWyn8pZUW2#=KnXtBw)%9 zVe9ki9r+)zv^VsK@%z{OUt-B%SNKOl%VUS?{cFyCw#9x0VS8Cq*M0bb z`>!D=8J2LJ?;0H5`g^nWH~;h7{IuYI7ejlSo9B|;fd3Z}M1W&US^akt>A!X}*Tp>X zR@qxXW=itpnVOpjcAWqBg}AAg>Q>5X_YrymtLtCZyRXVjj$*>Cw6}nCjO9_n1K-HO z;{adJ0ecsY|Gh0e7BBeStE0D6$TYom z{;V#nHXt^&|Js?)0BLyq`o*oqpP}>h^1@fE|*XvVE>dy!>&4&G7f{A-E1?tzBy=fnC*z*=hX)L92h6U$I&Kc;+V|6}j-hHQ!B;hrReXa8e1rDAUwcX)e)9rlrL1X4;>!@? z3#+A6!cLaWpY_|lz}{Fwj7|b;U0G@4lz!rKdr};ft%OfpTyDr0%B-ovxQ{7eP^L*V zrLm*+Y=y=(gEn#}#pi@K6q3^aE|sFCb;g9QEs$HkEi;3437hF+qphR3SoAG@Aj@kl z@PJ$4rsXU)8{Wc5V@1-d=|fvP5S<`IJOUtf$N>!$w~vKP-_u^o@5?6djdcMqYgpGy zF3xs^tsGo}ntRVVXQNIHN3UD}LHj5PdxY_@9pb7-Q*^-J=quqokmy#15xe|bTxwrZlWduV^y=oVzCR+I)ph>_{0x|YWN4aZ4fFt3KpvnzwDqey}A0*s%H?P)qNZ`9>F%*KQzhH4ylqy+8S$PjySEnNAq*E1?xp-ijo z>Pb0vouY?AcIGjbtw~N`_IQ3e>i0FHZH{pwKq<18e>=C4{}GS)rablf<0_i-S#dqT zu=OGiUA-gkDq?HYP_AVA>W2wy$aFpDlqiA07=xl2w0nM}a_!?64$O42$BWyCK6eCn zH-3KU$)euZA4a*jY1tf3-B!6U(HP94rqtw$yiBnSv@L~qSOVp0r^1@H`7^y>3G1(s zF9eEp))(o<i4){~5y7_73xug8GE@CN6>#z4W6U z^E=HHgGHtc^iT3F)mkh2IxCZog*)=3i{L_s-EN0$JZ=B~@;czv3KZmYj9R|#FmMG*{fvO}R3w5-$9T|*x#lZ1mFV4|-*f%O=ZD0kyl ziSqP4UA0@>G41sCy?{Be2RGw-a)xO^LNO_bVwe1si=LgBe?~Q;3uY#s(6>D162+p$ zd09SI1!S3lu`^Py2!tF#{xXN@WvQ)1c49#y#tB6s1b>P&Mz{k>J)OZBDlD9)Y!UQ= zmS$y-8V@5ZAW7-9p+UY51b?dT?r2Y=3jIS2J~d6^dIZwp@^y-B z!w^LLLTG{QltJF#KzN==OXnyQM##gYJ~sQ;|7bsr zOm`8*!;s;D@!_~km&=pj!de?>t@P-FeA0fzWg`;hSPI+`92n;X3Bt!6MX#%EuT~ib z{XAFWV8?o+`LRA=!V{vE*;6WSyg;dB?eSe6mfUpH8+#M!!31<-V-g6N{Nqf=T?I{% zYN3Yy!g`OV*|i37UiWs}4p!5yN$DS$zrB)52^$m`CmjSYDku)Xa9Y? zBsM$Mi{-EeCV$q(gVfzWI27{y=$!ZIOJ^EPB*U!%rm-Uw8m;3S<@KU4A$Cq4N`Nt=3;Rebc6kUnEKdceO#Dj1>vhgn1 z&H&S2xWh^%Iwq;VCMlT5{3J2S5PF*kg7P-zE)0&QH@53B8TQ3nK(-*Vs@jW<)5(QX z$0TS*(Ow=f+Sl{fS-uiCq{X=D$>xRXl218cr7z)RtzxI%&Z*`}o zrLVU|cRB^k$#>7rWLQ-apMa@PQ;9H%k``7g*+Vsimj=LzCeN(0s&E=@Dt9o@)90MKJWJQ*6 zIaVo*apo#H?OwAeY7OG45@Txlzut-rhl{0xg)EA!u8LdJ>j>KU z;5g(Sb{u5n(rMX2ER>yT^9WHBbJ$~#bZ>j)2se{Y$BOAK;)tWu=0(xTCgFM(;bxRk zIJ7v6xa;3x=7wjr4j%!)IyS^xe&n8S6?K>&@lJEoWKW+56q#mh=>`Xp88q6AsbHXRUc6$3M=Yd5r&?q8Jg_}UCmCU0iM&WG} zA;nQw{-A9Usbfoo0)w4ms-$#jGu^C^4V;o1iI#sWDoLv?1=~Hs#yQdWhIP-3s=rVJ zD^F(3_NIe6$N>GGlM07&k6B4!%pz?cIBC_SG*OUYo{kWwW4XM0Pt923KEg#4Iynew zUuF6%_3$`N612}$KrW!U&|JUh8F}oKxr2%j+K&(!NCU7C43%UN z;IUzn^5mZkkRR%^tC^FaT;<^HrNr#@=Uab7XW_B&y&xAg&G^au>?58Z!Gnqr-cPr7 z=NzKMo@sPKs=&5KQq73b$avbX042ssoe-+4e7VML#`iW6I5K_IfFRM|xk$7GATS;h znLrkgqQ(K*G85aglFBRr^V1ymh$7)oyEPXa(BGP(t=9{Ot0fE!sE@5C99KpitwKdg z`6Vhb7fD!3)=cFF-bd`AdKE&&7kyXgY+do90T5>0;fR=!zak6}LSM2F0!|(&sBmy6 zsWa6s<`61d1zEmD%(6dsuueXOMeVN_n!f?aBI`>bM~O?XeFMuy$Z$T7VBFNyf{v%7 z(xEZXnwdpO3M?!=#*NhY*P{Xhtu+7A6OFZX5o@>OFd^px<2?#i)<>yJuN`|@LWfvR zHj|Ge{EEV%+Hs&c&6#)Oh0h~WM$@Qb`}8ND{8;_{ln!R4LT^9IUndbl%;*{()<+#z z-u=lf(RpR5lbK_6b- z^hsx_W@2m%FJw$FtSoJQX6LU1jw?MQ1XC5?ZG>51;EHtbIOD>)rq$vrj*4+0d2t{o zvBUS{IS7g`t&6WvoPrvvH+5B1LMZxi>T$jFr%_!2ZyVdz zkfekFa|h-D);^lq)BG*&jNy#dmf6$ok&!R{m?=_Q z%S`wz@}3*qAm+h?P{X~s@Ag|hm$>r1yvRXxpruZ5c@%~l5!}(}x;wTz*MlyW8-UU@ zS77*61Hn%p!B1MEYZ}2XJIdFj2hVJxT_*(`l2`2cjlyd4S(V~9D+Z2Qsf-7e4=40t)Xbb{T`b&&(9l&@ni~}Kv zVCE?>)4#_CYAY06T7H2xq{!dsbXv{h`qv%O`g(%{c_R*3Nl=e%#GI{wi(!(%tFrej zDN%d3Bf*aw`)1Pc_NI~6mx8klo*NZLK?=h$x7kqz1g@mhiV=^yvgSkm%}+>aL=o@LJ?+*2<; z=V}s`%IlHZP^-cr-sOeX!!xh)+-K^F;?kPK*Am%o9Uxrl7pL-1dC>-X|4&XudE~uZ zFtF4}#GRTm=uK|*$7lWfRsgcH_h7q-atVKZN#{STidPcAU6X4HS4E^k|55XvGez0D z?oZ|d&I%7#buHC+mwkYtLODN77K4DfSE*Oeq>MV`JBwB&sg{H=LyMpbA%36zhiZ4e zRhDk+;187z+=d7*6#p!T@+{bP;YL5wk55LhbtPAb7)dL`cQ7bM6uv0M=$nDh7Tu!Z zJgMk#Z*;!SvKjwhD0KG<6mXWs2*>Rm%=#35?4Ocx3fF{J+O=)vK_i${8Z@$E%9zxU z7jcntq%!5Zaith#Ow{Fq&P3PBko?MIDxlJ0?CO6|)v?usQJu!hP$)^$;EYK9%dRN? zq{11Hx{vy)NxM3t)k!ByiFJyFkfQ!pJ4TI{48cY9P-8-k7l(vFB~mjM5rf9W7yA+S z8jeoO6yCC}5r@Jo0`-XU_=;F3(EgvAftiT9Gs<|vQA#2s#x#H0j;fYf!P20*(X7M>fiMiNsTh+*PM zD>sbmrGId%#IJa8qeQO=%Cj(LDpc)XF@r;D2^ViKcA=(FhkVVOJRmC07qW@s zrdk_Q#}l%L5}-=Mr`BiMQgvAccSvGTDrE78GzZL%$i#6&5+ddiA6U9kRCD z1T@@h>;M#el;*JL2W=eFj#QDhAaB-^f?Qy~&ZQMs!2wTbftI}^8b>KMrtbN4@?npw1 zzjzVl5|86@+A8Z2vP(OrPpl@! zC0p+J^8nXsQ2bP0#z6&Tv7Sa1QfIlpLL{+oiV6xwKnE0k3qrN@sb zmI#Egds zoir5%3)L)5-~@mdVq%6Kn(%A!EoCVH zVmla!*TaD3AbJFw#R-ygN_BRZ)Kt01#m0c1YAu`P)vn_R?vv{52y;|Bu zI{o|a!lrq#mcrg-v9?V%8hU9Pil2EJUzo-#(SIv1kJI`Xk=8pgW-G6d)B0pABlWc% z6m1^jx3v+Z9b7Vpm`o;E5u*PMrOgbK{S2k^43zs!ah#m!K*Mk1yC%6GD+=}I3dyQ@ zX`tp-pz)A%W_dBb33n>DvV1+xulxo%@_>RH7W~gAhBtsOATMg@W~UrD3Hzi7UiVaD12! zgo?T~cRoN$86i&L17AdxGC&fF|A7l|?I2g0y3)tu;>yR$$^EP#3y5KOVE0BQrC4f2 zXlztzpQKU_Aai)MNjR18`}=7^6JjJ|LIZ*jvG6^6B0E(^gwg2MY0{>eoOv$)5C}FA zA@r}6kHr}*7sBB|alt*qQSf)a=)%-_Afs;eR-WZ%%)O8~!7~@QwbVZY_UDimqm&s- znJ|q#?j(T?+4Gf@WU!r9=U}+la0P&GU)G-$514w?tEy0$7^pG6<$nPVVwAoxFBnLF zoFoSbQ)Lyo=OnwkGRqe+L;C_6Uvx%)LG-Kj?oVB$l+VAI=nL2U%WN|*dS028QCDzr=$uUg2s8- zi4%TB`h{zhNP>kRcra=aduBzIWS72IR*eLGu_WSKB7NM5e8V$rZ@5rK92>NVNLSK7_opa`U~kCDQcbkvv5qw#HnZ5;GZ1+hsdO{ zGYpOBM15sz4uBiPLXGs_;F&#{pfDj} z20(1Sn~;3*np>j}HRgOFntVqmHnAL$_++Cjh2J2cN)6UW;D_qeh3P`rMIhdfA^Lg5 z&a1b9sPyrdJC_7BsNl4&uP?H32zDJRp26GD92hP%Q%N5gGV zne3F!IP@_m-b|2h^w9)-J+@8aB>L_XZaT^F8EDoLg5fC+M5VcuRSc4X@hA=?rMXm8 zMmc`>QP5y3^}!~9fl_Qg(6spVIZmgNFJVsE#A<{~0Gbvh_iWBBPvQ@U8|Wv_eao`# zTF{bS$?V2!FW9X3qCKsn%i#`y^C-AYeo)c5#%Go@Z#Yc>P>6X>IgKlcZkuMGzR)@_RGmw5`3rs?nhl>EPH|MlU+S!2dcM*4g*iC#kkq)Z51vE+&@3v>s!dcXKn0X@D19c*qd8{{%aA>dcvKTPU> z!#RElPPwl8R5-89LLIEUW&ee4XesxPa(zEe{(nSnPjSA3+}dD{jZqpa{QTGGxhk8& ztz|$?Y{ISbr6jso2GM++dAP&~D*Ieq(NXOF+m37Ply7ZLj$p#A-&@b=TwmoR2}@7jw?Aa?j@=ZTLnH*D|r|E@70Y!B$^OAu4DckLFc zr}gnM^SCKEynOw{gKs#zcjo9z5L?rK?Z(%IG`!4=xJZJys2vLx{_f+8UoAO&hKkNq z?(_@U`WM2AWYz}H)i4|X*Lyd6Kq9ciWDzairw>&6nN1QS|Mq>} z7g-+$opP}TPzT51tX!5-NvYbzJ=8~~BJT89gD{yx?mbN071GPqn$nVp zpZ>IvFUS6EGCy%mADE%ki-Z6*p;kfXkDu7av@dxcVq)B}4ZEtdLBM46r+q9=EnEnZ zb4%>Vd&Fdn>>4$2%v1YtR+!+Yiu+RElyD!ysnhj{rjDDRfMUR>PnnzHvHid+7Ah%h zeY1brIiMUH9#!b`g^DY2Bk*DcxO6hz;Zgq|nOpgmllm&LJ7X2~NBT3;(T zhS*m#J{L*OK(nKhEM)zTfnXnBUy9FJIV0lclp`1uRyKIe=ubQF%E{e}op;W@9cGTV z_MKl&4Wtcj_TKHumG8`D?lDX8kQ05-QH1jXPkTddmqP%|Pfsc;c_0}09G6oA##l+t zueNR8lghL&+tQ4$KBfi+f1oMhLMlisAp?yUe$Tb)(6c}oafFbRgdMTXgzOl;YeNkG z!?olH+}^AsK2U@~3@R;h+^QFwu-*?^4nQL70W!jp)WU3}fw;2-g%U4%JPf+htHr<# zl@~Ju0zSTZ^$szdPO~6|&gw=*IW{hroSJ@BE+_uOA#yrJnf-|_dm?7dK*s^^mQ%h> za*Fp%!A$Q?MqGfGDni8$^&8mA62;6-A4Ye>FFOo@Em|L6-9qc{@0X$v*)U`r*gnm4 z2Z#b&^fH-~eE9Ka_9&(2CxwjUq7Ef$J05=s)}%xMv`V$gRz}}fjSFUaQc?qgC~e2d zy7QraScBqicsa;}4Rtmp#4)2Q)N)VcD)T`7|_5T>jE=**ZD{iRaA^ ze4&Ep?=|C$gKLdh7~5 zCb&zkI#_W%i^rd}r(;JV-}PQ9yl(zfo`qp*MSn1Um+@twxvnc7IKrM99z)HQm1L9C zThy|^oi91FE}e;xuvUn+R`ezeHVYmXfjkq*SH{I?)H7DF4YO9xL4~6Fc|G^%TM10j z?^#={hc$Ko1+$E#1}iAt%z~pG|Hl*P0$t*(^gdt9c8k=VlHb+ zx^j^!6P@5jhA37+4&kJix5>CwaIwo?TCq+ zP*qE{i#pE|Qt&sov`KsiuDEl;b`I<3Sw#KrNb8#3s&V$w9z=}6Ob ziARzk0=qP>)^}O0Ukp{y>L|+}{r}hmA+2MAo(aXfC6}0n8hVfzYoL8YDT-IDJZwXik624 zX|Cw8G?epFngVw=Kfk@CKK}aRURpudCs1p%_9{cFuG)|ozZrb)M+Rzg)^%5(3nu9ac;aT+d6 z{@`jtBN3uMV%Ux&h|lXI9xP&GoI+!q!FhzlUbw^D-yF02>n8o%3=DgBr=ba)zWYFI`DeEsk1hm?}vZ(@ks%M?O)ta|WW zM(NteH8dLRG`NNsyQJg|5hr^d?&N541r%{l&IGBZ-DbSdag94SQ{T%I(rz!fd+N>0sVSU=Pl zSFB&b=>SHfu> zph`g~Rc?XlhvnEJ$$8|-UFpO<3Vy80C)7kDfo08htygmvJH+!RO-2KN!dAahiAoo) zlJd?j7&3UWAFAGV5^PQISU)79n&9Oj$dHV6-)##w5~^f|@o~q)>S;Qxcmo*Tp6S1V zX%(-S=qtU(NoeLjK2x_s(X^i98Ik-nmMLdG#;dhMlF70u_Ja)S`em$*p+DU9OTqA` zAk{(AcscU72b`D|(}a&)Zi|gs zMSaf6&`4uN)2MiS4s3t4`Y>t_r0J{4RmgvX9iA=-!x5?8_!b_c7AYn8&91g&g4Eg+ zABaACZs`;rD}RQC;O?W-UuH?bW!u9MaBWMZky<9G3@5RXg9#R!@+m>hNKqEW(8M88 z?G=;?UqBor$wlCuz#JrzV41RtBcTWo@wdv+--)Vo!}b)n8ha~ z$I_>}FoI*tVPBn@TK#j9>mWi4L>m863j*W|fEHSmgHz0dQ}Mu~G!e^05lh{}2F4%n zoX5IoCRJHN(Otx^?g9)5fPw}R$f>3pn65y@@IO!A{3OSd*hg3@C61`_Hq8mf%0#uT z#$p}srn_QdYk|`(K;-kugYh~!s;asUBHK`ZiSw`D(f$k=YdyAlleINR1vSG-oSp{F zsN#rfcaBcaM!1SCLY_<{R9W$=3V9NSaT)_Z*Gd*wuojNtZBnSr1E$QKpS-0>_C;KT z2Q*va^lIbuT9ov-fB)*rH#dwoQ{Bf`ImLgC8DT2d>u?4`eEHR(01mR5>BsJ>{dn}( zzA(ue7nxwA{h)(ma1w(;Ap@->9&NabOy$&7_0;vnU+#K=*H4fZf>6(JJayqhmoy~C zHD+oc(7q#pY8#+odI4en+{7i&7TH{nqRQp{O-wEr{yQZ6_YhRHUM6kNcf`=4K8$qX zh>2QuE#AV3Sx@6DI(+``C5weX8*PE4Z1|vza?x8SrxW~_`tx-#6klq|)-0{euJY-w z@1yI^DjW%!d46QYiH%ySbWC9|QKrG-PV0eaM`q28mGSk(txEmBCa&Em(ZFUqL^6K0 z;O?bI5>M$dSftTz#f@_<&1(U!*|1#s?9N6afz4N_sc*)Y28|CBusuFe+ZVnh6>dv; zHck&=W9Q|ED~8;|44+y7Bf|{TCJwQGi+=wt;t%~MVKaN_AqGj6^?O7{XR%4M?SBN) z5-shdEgjkKh%(AKbep~?K-~NLWH0nqSJsgkis>9AEEhvW?=JuhW< zsE8kHXt@N+#Kbt83vv+{qzEW^5{2{F_~zC(Hz2p$l%8ZLR56Af*Qdg6rGcaFVyw=c3@~cvz#ilC~T}SI!dS=T) zn9z7`(?vo`62+ymKG)9fB;gb(C1;@9^cSI(MrZPD8nrGpbAYj`=vlMIB+@}?OoOC& zj-7sto_sEJ|1ss}bF_g;uD5YCW{^})82%szI$SEXYK}4bqQcgl8eZ4`4U&X~OGDnN zo+N1BeA$hRPIyItJ3WRY`NZDn`_S@*9!u)F_*i}NHZ z+%U#a*QnVDQ?k6(AeZQ3r|QV>aS=hX-fNY>+CC1-3e_6p6gwl-Dbfv75T5Tv1bVW`e;gRciJiyY?s}<-j9$ z90U3q$rzf;Xf2kBULI>()K@N}l^j;-QI!OqIr>C*douxfbA2Tv`8ol3U0&14TRKfR zxL1&HSY!^v4ILE-}CP+>a zLg#e)HxFB* zjRdZ?evsc0E+bu&BKzBeVDwa^cpWyPc( z<@78pKTB*V8~CIxd=h!%@@dP#>>8IhXvyrcboQ8PY^IC0Gf>;o*;1?wR*iL1Q94@x z6lH}uE2iisIr!Ly;x*nfdP{P0MGVmTSABziw`wPI4VH zn|!1;`OIzdk=*1nyU9m-lUKC#qnYv0zp4=WDa)sJI^}M8Ql6GK$yl_lNJDiP;D)OYi&oT$XThoe=Jt{xmYD>?U!lo7pkA3vCJ|K?R}n<(?x=ym6$eu!RLoYGUu@o zp21mWVZ1UB2t8KgdFs#9P%F0K{A#>}sdtcyL+Xu7piM>67ZE+5>Qc5c4O;<~OAE*M(@4#u(+9*?lc54}}e zKQgvejGHm$8o;#2$`}*!2~{__Ur$Q8CzwseAB&lr^EQYP#ESqRzRLuG{IMw_(h!|& zicTs4*ZH>quC?iJ;>J1GmR!H$T9ZBY0QQHp7s0-89!R_0{?Atf;8usHzB}u9n zN9$G&-Ajfshxoe&(r%3}zDgzVBSO8PXyx5%We1vH`F8`kMnl}V-1g0((Uy(Ii&sVG4rNRWyV+!iz4QZE~GM`-Q{XwIQN1K;#K zwz%%D_T;4eibRtd&#`_{mAjmHqa&q-v(W?dZwnU;4rZUnc{7j5?!4|3JCL8(eP*Ah zZI$Hp{CK{*^k;vcYy>W>+mbL+Th}e=1W&WC=aL@a^2Moj{}r|7I_{h=(Rjb|VXA-l zPgx=W3W+JBunLU`Q_Ef01!phw_u*JS$P}x2o<({R+bjP?Pov%*aFA?iLDsh=L?qOde;{`pJKt~scekBH|r(T6Q=bDmPeB3t`rpN z4e|5_+9xiT#Mt_^)LicAqaN`+OHO)2vmSA1zgT9qK`~C2BMa9fZ%io|je3Ud9hApf z-Q}M;^_*@!r&G^y?0w5r z$23@;Y1lK@>6vr(C}Qbb9{Y03G#^rh%%IV0{U7!Od57`5sjp;+(c^{pgYI(lTS@KjbV*{AC zVNW$c$kiANz;ZC(i}kY{PW!?#V)2dzex0%U{yg7JkYXM8yp4!Bj*`&>cp!C1G;!*H39=c0nxgRyL9LNQNa=F4L^j7`7tER(V4?btFw zV*8a-S&S9G*zacSSFv8^Rdh22(tl8OGQ+W7tMVXF4u$EfplL96Do_4NGV!M6!&s^~atE*sx1klGi&y?Ph0;7y&g6BvF8fedX2yeA+HAr!;cxmK^ZQtX)J{+ zo-IN@kyh^x9|%{4L`}z3f#w7ue2NkNzK{bRks*&r!XuLMhwwnqXE$*@LyL7=Ga@fn?&lHN)QnyPT>*3#j#a?ST*0JY z?dD(YBw+1kVC^Jb?Pgu=q+RXiUF{@Z?Pgx>L#1n8(nm5a41Vz-p|iBsCRjHb6&~k<0D$5wt znraYSLn&bWD%S^@9J;d|Tm{{qq4rEy<(X@FB3DGwenbpvtC|jhtOS>=4p?iN<-nOY z(gi-ML^4vb zjd%b4n=y$Buh1h=OOfs*Qn;MKg#<2TWx@PcPFC#;!w0Rx$Zo8lLS)x!skO6KO6eIu zgz?P;x=KtRSa9TJ?>Ir%84}Lnh5-N;%=W;tt)nGEYz;~aS47^brg)?o2hYoiNsZ!~_c z!RM@}7KefJe?ry#?yp?Q0YPR4Mdd;0|KQ)~iQ{qe38H{d7wQH{cD%zi)P=FB0nf`7F2kH7?-%74j2iQ4#q z)A`8fd@$i1

^v+=L%E$Wb~v-`S{6w72Rk)XRUQQC@wp!kxDFKN9PIe#h<(<0y_p zki_Nd{{Ey^qinret{In*pZ*JKDe+cuvJZatM|@-#{LJ3>(QjGG!wr4!It zrhO}{^di>#m3Ll)_O8n_uR;5H*nS=?XFOi{VcMh1_A-$i%l@3=m>J@lF++`98sDo- z_APPoh@+$S7#{i7W5ZG9t*39@J;rgr?W$wn&JaV*!`bNU@y;Hsd1h=8xN4d>Yg`FT zHREr){1yv;f~bySI*61iP7032aF#5RWaZC-(j;z3HLoY zm(~!T<61h1vx;{GO5Tu8HxTq1vi(OAc_(HUxYCDw=>w!>!<=NOVR^S9>7b~Qsv5z! zZ(a;kW4v2)9g+%hsm>0H=rmP#fv46iC9E!HN9sshlME zJjL{xpae)+t_h!Rif6+03rhk?-vkG*vTYJ?R;ub2%wJ8Cf$c=6n?nzwN^Otlwb; z*h`<`PHf$Y4d&pj8F|CD+>WPU?iSxpBKIwiKZ9l?#)I=Q+>)NjF?To3>qIwvPN^6vR-agzcUPAl+4W!@zd z?hI+$Ou%zO-AA$-84`2ARG$WBmJNvfRn_pVa-rZ^IyQxnZC=grqM2MY(~D;3qS-A| zt#q&&<+!TR@0n}56jUf|9TweVbA;qIOO%MULpY{`_jGbrBLFkvSo?Dgdpb>B3ZueL zj7d2mg=8_!WHDpIDLPz^xg{h#6OOIz? zHhY8j@V5JXht!R?^);(taJZmyXNeM+UGvQ-oHD|5M22v$c6b#dtByFaBx9>c&`;5z zBQ&g_44o!Ml@9W3b~xRm^%36H^BKITv07!PeWdZfpJvtch+OiR`lYf**z+TWJc5az zZeY++L8SA_)*b=Jk6nXZ@XF#Kb-^PRf$l$m%d^!_xTw_$LNE;-tvyc6C)WGU)~^g~ zI*icX)?o4zx?{)h#W}hg^}MKH|DGWA1QfHjKK?&Gj*#*&Eb#Yqkb|5<(2nX-r2uhN oOQa+Tn0G4OEkU!FGRU$g!_o1Qdz9^S&-2Is2gPsnr)TT{0PXD}+5i9m literal 0 HcmV?d00001 diff --git a/app/core/__init__.py b/app/core/__init__.py deleted file mode 100644 index c31b827..0000000 --- a/app/core/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -from app.core.config import config -from app.core.constants import ( - ADS_PAGE, - BASE_HEADERS, - DEVICE, - PREMIUM_PAGE, - STARS_PAGE, - WALLET_CLASSES, - WalletVersion, -) -from app.core.cookies import load_cookies -from app.core.exceptions import ( - ConfigError, - CookiesError, - FragmentError, - HashFetchError, - RequestError, - TransactionError, - UserNotFoundError, - WalletError, -) -from app.core.logging import logger, setup_logging - -__all__ = [ - "ADS_PAGE", - "BASE_HEADERS", - "DEVICE", - "PREMIUM_PAGE", - "STARS_PAGE", - "WALLET_CLASSES", - "WalletVersion", - "ConfigError", - "CookiesError", - "FragmentError", - "HashFetchError", - "RequestError", - "TransactionError", - "UserNotFoundError", - "WalletError", - "config", - "load_cookies", - "logger", - "setup_logging", -] diff --git a/app/core/config.py b/app/core/config.py deleted file mode 100644 index 52811ee..0000000 --- a/app/core/config.py +++ /dev/null @@ -1,46 +0,0 @@ -import logging -import os -from pathlib import Path - -from dotenv import load_dotenv - -from app.core.constants import SUPPORTED_WALLET_VERSIONS, WalletVersion -from app.core.exceptions import ConfigError - -logger = logging.getLogger(__name__) - - -class Config: - SEED: str - API_KEY: str - WALLET_VERSION: WalletVersion - - def __init__(self) -> None: - # Load .env if present; env vars already in the process take precedence - env_path = Path(__file__).resolve().parents[2] / ".env" - if env_path.exists(): - load_dotenv(env_path) - - missing = [k for k in ("SEED", "API_KEY") if not os.getenv(k, "").strip()] - if missing: - raise ConfigError( - f"Missing required environment variables: {', '.join(missing)}. " - "Copy .env.example to .env and fill in SEED and API_KEY." - ) - - self.SEED = os.getenv("SEED", "").strip() - self.API_KEY = os.getenv("API_KEY", "").strip() - - version = os.getenv("WALLET_VERSION", "V5R1").strip().upper() - if version not in SUPPORTED_WALLET_VERSIONS: - raise ConfigError( - f"Unsupported WALLET_VERSION '{version}'. " f"Must be one of: {', '.join(sorted(SUPPORTED_WALLET_VERSIONS))}." - ) - self.WALLET_VERSION: WalletVersion = version # type: ignore[assignment] - - -config: Config | None = None -try: - config = Config() -except ConfigError as e: - logger.warning("Configuration not loaded: %s", e) diff --git a/app/core/cookies.py b/app/core/cookies.py deleted file mode 100644 index 4f43be0..0000000 --- a/app/core/cookies.py +++ /dev/null @@ -1,32 +0,0 @@ -import json -import logging -from pathlib import Path -from typing import Any - -from app.core.exceptions import CookiesError - -logger = logging.getLogger(__name__) - -_REQUIRED_KEYS = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") - - -def load_cookies() -> dict[str, Any]: - cookies_path = Path(__file__).resolve().parents[2] / "cookies.json" - - if not cookies_path.exists(): - raise CookiesError("cookies.json not found. Create it in the project root and paste your Fragment cookies.") - - try: - with cookies_path.open("r", encoding="utf-8") as f: - cookies = json.load(f) - except Exception as exc: - raise CookiesError(f"Failed to read cookies.json: {exc}") from exc - - missing = [k for k in _REQUIRED_KEYS if not str(cookies.get(k, "")).strip()] - if missing: - raise CookiesError( - f"cookies.json is missing or has empty values for: {', '.join(missing)}. " - "Open Fragment.com in your browser, copy fresh cookies, and update the file." - ) - - return cookies diff --git a/app/core/exceptions.py b/app/core/exceptions.py deleted file mode 100644 index 9a59b65..0000000 --- a/app/core/exceptions.py +++ /dev/null @@ -1,42 +0,0 @@ -__all__ = [ - "ConfigError", - "CookiesError", - "FragmentError", - "HashFetchError", - "RequestError", - "TransactionError", - "UserNotFoundError", - "WalletError", -] - - -class FragmentError(Exception): - """Base exception for all Fragment API errors.""" - - -class ConfigError(FragmentError): - """Raised when .env is missing or required keys are absent.""" - - -class CookiesError(FragmentError): - """Raised when cookies.json is missing, unreadable, or has empty required fields.""" - - -class HashFetchError(FragmentError): - """Raised when the Fragment API hash cannot be fetched from the page.""" - - -class UserNotFoundError(FragmentError): - """Raised when the target Telegram user is not found on Fragment.""" - - -class WalletError(FragmentError): - """Raised for TON wallet issues (connection, balance, account info).""" - - -class TransactionError(FragmentError): - """Raised when a TON transaction fails to build or broadcast.""" - - -class RequestError(FragmentError): - """Raised when a Fragment API response cannot be parsed.""" diff --git a/app/core/logging.py b/app/core/logging.py deleted file mode 100644 index f701f52..0000000 --- a/app/core/logging.py +++ /dev/null @@ -1,20 +0,0 @@ -import logging - - -def setup_logging() -> None: - formatter = logging.Formatter(fmt="[%(asctime)s] - %(levelname)s: %(message)s", datefmt="%d.%m.%y %H:%M:%S") - - console_handler = logging.StreamHandler() - console_handler.setLevel(logging.INFO) - console_handler.setFormatter(formatter) - - file_handler = logging.FileHandler("FragmentAPI.log", mode="w", encoding="utf-8") - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(formatter) - logging.basicConfig(level=logging.DEBUG, handlers=[console_handler, file_handler], force=True) - - logging.getLogger("httpx").setLevel(logging.WARNING) - logging.getLogger("httpcore").setLevel(logging.WARNING) - - -logger = logging.getLogger(__name__) diff --git a/app/methods/__init__.py b/app/methods/__init__.py deleted file mode 100644 index 590e0e7..0000000 --- a/app/methods/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from app.methods.premium import buy_premium -from app.methods.stars import buy_stars -from app.methods.ton import topup_ton - -__all__ = ["buy_premium", "buy_stars", "topup_ton"] diff --git a/app/methods/premium.py b/app/methods/premium.py deleted file mode 100644 index 798bc6e..0000000 --- a/app/methods/premium.py +++ /dev/null @@ -1,151 +0,0 @@ -import json -import logging -import time - -import httpx - -from app.core import ( - BASE_HEADERS, - DEVICE, - PREMIUM_PAGE, - FragmentError, - UserNotFoundError, - load_cookies, -) -from app.utils import ( - execute_transaction_request, - get_account_info, - get_fragment_hash, - parse_json_response, - process_transaction, -) - -logger = logging.getLogger(__name__) - -# Page-specific headers -HEADERS: dict[str, str] = { - **BASE_HEADERS, - "referer": PREMIUM_PAGE, - "x-aj-referer": PREMIUM_PAGE, -} - - -async def search_premium_recipient( - client: httpx.AsyncClient, - fragment_hash: str, - username: str, - months: int, -) -> str: - resp = await client.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ - "query": username, - "months": months, - "method": "searchPremiumGiftRecipient", - }, - ) - result = parse_json_response(resp, "searchPremiumGiftRecipient") - recipient = result.get("found", {}).get("recipient") - if not recipient: - raise UserNotFoundError( - f"Telegram user '{username}' was not found on Fragment. " - "Make sure the username is correct and the account exists." - ) - return recipient - - -async def init_gift_premium( - client: httpx.AsyncClient, - fragment_hash: str, - recipient: str, - months: int, -) -> str: - await client.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ - "mode": "new", - "lv": "false", - "dh": str(int(time.time())), - "method": "updatePremiumState", - }, - ) - resp = await client.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ - "recipient": recipient, - "months": months, - "method": "initGiftPremiumRequest", - }, - ) - result = parse_json_response(resp, "initGiftPremiumRequest") - req_id = result.get("req_id") - if not req_id: - raise FragmentError( - "Fragment did not return a request ID for this Premium purchase. " - "The session may have expired — refresh your cookies." - ) - return req_id - - -async def buy_premium(username: str, months: int, show_sender: bool = True) -> dict: - if months not in (3, 6, 12): - return { - "success": False, - "error": "Invalid duration. Choose 3, 6, or 12 months.", - } - - try: - logger.info("Loading session cookies") - cookies = load_cookies() - - logger.info("Fetching Fragment session hash") - fragment_hash = await get_fragment_hash(cookies, HEADERS, PREMIUM_PAGE) - - # logger.info("Retrieving TON wallet info") - account = await get_account_info() - - async with httpx.AsyncClient(cookies=cookies) as client: - logger.info("Searching recipient: %s", username) - recipient = await search_premium_recipient(client, fragment_hash, username, months) - - logger.info("Initializing Premium gift request: %s months to %s", months, username) - req_id = await init_gift_premium(client, fragment_hash, recipient, months) - - # logger.info("Requesting transaction payload (req_id=%s)", req_id) - tx_data = { - "account": json.dumps(account), - "device": DEVICE, - "transaction": 1, - "id": req_id, - "show_sender": int(show_sender), - "method": "getGiftPremiumLink", - } - transaction = await execute_transaction_request(client, HEADERS, account, tx_data, fragment_hash) - - logger.info("Broadcasting transaction to TON blockchain") - tx_hash = await process_transaction(transaction) - logger.info( - "Premium purchase successful: %s months -> %s | tx: %s", - months, - username, - tx_hash, - ) - return { - "success": True, - "data": { - "transaction_id": tx_hash, - "username": username, - "months": months, - "timestamp": int(time.time()), - }, - } - - except FragmentError as exc: - logger.error("Premium purchase failed — %s", exc) - return {"success": False, "error": str(exc)} - except Exception as exc: - logger.exception("Unexpected error during Premium purchase") - return {"success": False, "error": f"Unexpected error: {exc}"} diff --git a/app/methods/stars.py b/app/methods/stars.py deleted file mode 100644 index af21079..0000000 --- a/app/methods/stars.py +++ /dev/null @@ -1,133 +0,0 @@ -import json -import logging -import time - -import httpx - -from app.core import ( - BASE_HEADERS, - DEVICE, - STARS_PAGE, - FragmentError, - UserNotFoundError, - load_cookies, -) -from app.utils import ( - execute_transaction_request, - get_account_info, - get_fragment_hash, - parse_json_response, - process_transaction, -) - -logger = logging.getLogger(__name__) - -# Page-specific headers -HEADERS: dict[str, str] = { - **BASE_HEADERS, - "referer": STARS_PAGE, - "x-aj-referer": STARS_PAGE, -} - - -async def search_stars_recipient( - client: httpx.AsyncClient, - fragment_hash: str, - username: str, -) -> str: - resp = await client.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={"query": username, "quantity": "", "method": "searchStarsRecipient"}, - ) - result = parse_json_response(resp, "searchStarsRecipient") - recipient = result.get("found", {}).get("recipient") - if not recipient: - raise UserNotFoundError( - f"Telegram user '{username}' was not found on Fragment. " - "Make sure the username is correct and the account exists." - ) - return recipient - - -async def init_buy_stars( - client: httpx.AsyncClient, - fragment_hash: str, - recipient: str, - amount: int, -) -> str: - resp = await client.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ - "recipient": recipient, - "quantity": amount, - "method": "initBuyStarsRequest", - }, - ) - result = parse_json_response(resp, "initBuyStarsRequest") - req_id = result.get("req_id") - if not req_id: - raise FragmentError( - "Fragment did not return a request ID for this Stars purchase. " - "The session may have expired — refresh your cookies." - ) - return req_id - - -async def buy_stars(username: str, amount: int, show_sender: bool = True) -> dict: - if not isinstance(amount, int) or amount < 50: - return {"success": False, "error": "Amount must be an integer >= 50 stars."} - - try: - logger.info("Loading session cookies") - cookies = load_cookies() - - logger.info("Fetching Fragment session hash") - fragment_hash = await get_fragment_hash(cookies, HEADERS, STARS_PAGE) - - # logger.info("Retrieving TON wallet info") - account = await get_account_info() - - async with httpx.AsyncClient(cookies=cookies) as client: - logger.info("Searching recipient: %s", username) - recipient = await search_stars_recipient(client, fragment_hash, username) - - logger.info("Initializing Stars purchase request: %s stars to %s", amount, username) - req_id = await init_buy_stars(client, fragment_hash, recipient, amount) - - # logger.info("Requesting transaction payload (req_id=%s)", req_id) - tx_data = { - "account": json.dumps(account), - "device": DEVICE, - "transaction": 1, - "id": req_id, - "show_sender": int(show_sender), - "method": "getBuyStarsLink", - } - transaction = await execute_transaction_request(client, HEADERS, account, tx_data, fragment_hash) - - logger.info("Broadcasting transaction to TON blockchain") - tx_hash = await process_transaction(transaction) - logger.info( - "Stars purchase successful: %s stars -> %s | tx: %s", - amount, - username, - tx_hash, - ) - return { - "success": True, - "data": { - "transaction_id": tx_hash, - "username": username, - "amount": amount, - "timestamp": int(time.time()), - }, - } - - except FragmentError as exc: - logger.error("Stars purchase failed — %s", exc) - return {"success": False, "error": str(exc)} - except Exception as exc: - logger.exception("Unexpected error during Stars purchase") - return {"success": False, "error": f"Unexpected error: {exc}"} diff --git a/app/methods/ton.py b/app/methods/ton.py deleted file mode 100644 index ee5d49f..0000000 --- a/app/methods/ton.py +++ /dev/null @@ -1,132 +0,0 @@ -import json -import logging -import time - -import httpx - -from app.core import ( - ADS_PAGE, - BASE_HEADERS, - DEVICE, - FragmentError, - UserNotFoundError, - load_cookies, -) -from app.utils import ( - execute_transaction_request, - get_account_info, - get_fragment_hash, - parse_json_response, - process_transaction, -) - -logger = logging.getLogger(__name__) - -# Page-specific headers -HEADERS: dict[str, str] = { - **BASE_HEADERS, - "referer": ADS_PAGE, - "x-aj-referer": ADS_PAGE, -} - - -async def search_ads_recipient( - client: httpx.AsyncClient, - fragment_hash: str, - username: str, -) -> str: - await client.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={"mode": "new", "method": "updateAdsTopupState"}, - ) - resp = await client.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={"query": username, "method": "searchAdsTopupRecipient"}, - ) - result = parse_json_response(resp, "searchAdsTopupRecipient") - recipient = result.get("found", {}).get("recipient") - if not recipient: - raise UserNotFoundError( - f"Telegram user '{username}' was not found on Fragment. " - "Make sure the username is correct and the account exists." - ) - return recipient - - -async def init_ads_topup( - client: httpx.AsyncClient, - fragment_hash: str, - recipient: str, - amount: int, -) -> str: - resp = await client.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ - "recipient": recipient, - "amount": amount, - "method": "initAdsTopupRequest", - }, - ) - result = parse_json_response(resp, "initAdsTopupRequest") - req_id = result.get("req_id") - if not req_id: - raise FragmentError( - "Fragment did not return a request ID for this TON topup. " "The session may have expired — refresh your cookies." - ) - return req_id - - -async def topup_ton(username: str, amount: int, show_sender: bool = True) -> dict: - if not isinstance(amount, int) or amount < 1: - return {"success": False, "error": "Amount must be an integer >= 1 TON."} - - try: - logger.info("Loading session cookies") - cookies = load_cookies() - - logger.info("Fetching Fragment session hash") - fragment_hash = await get_fragment_hash(cookies, HEADERS, ADS_PAGE) - - # logger.info("Retrieving TON wallet info") - account = await get_account_info() - - async with httpx.AsyncClient(cookies=cookies) as client: - logger.info("Searching recipient: %s", username) - recipient = await search_ads_recipient(client, fragment_hash, username) - - logger.info("Initializing topup request: %s TON to %s", amount, username) - req_id = await init_ads_topup(client, fragment_hash, recipient, amount) - - # logger.info("Requesting transaction payload (req_id=%s)", req_id) - tx_data = { - "account": json.dumps(account), - "device": DEVICE, - "transaction": 1, - "id": req_id, - "show_sender": int(show_sender), - "method": "getAdsTopupLink", - } - transaction = await execute_transaction_request(client, HEADERS, account, tx_data, fragment_hash) - - logger.info("Broadcasting transaction to TON blockchain") - tx_hash = await process_transaction(transaction) - logger.info("TON topup successful: %s TON -> %s | tx: %s", amount, username, tx_hash) - return { - "success": True, - "data": { - "transaction_id": tx_hash, - "username": username, - "amount": amount, - "timestamp": int(time.time()), - }, - } - - except FragmentError as exc: - logger.error("TON topup failed — %s", exc) - return {"success": False, "error": str(exc)} - except Exception as exc: - logger.exception("Unexpected error during TON topup") - return {"success": False, "error": f"Unexpected error: {exc}"} diff --git a/app/utils/__init__.py b/app/utils/__init__.py deleted file mode 100644 index b2d51d5..0000000 --- a/app/utils/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from app.utils.client import execute_transaction_request, parse_json_response -from app.utils.decoder import clean_decode -from app.utils.hash import get_fragment_hash -from app.utils.wallet import get_account_info, link_wallet, process_transaction - -__all__ = [ - "clean_decode", - "execute_transaction_request", - "get_account_info", - "get_fragment_hash", - "link_wallet", - "parse_json_response", - "process_transaction", -] diff --git a/app/utils/client.py b/app/utils/client.py deleted file mode 100644 index 2ed4624..0000000 --- a/app/utils/client.py +++ /dev/null @@ -1,39 +0,0 @@ -import logging -from typing import Any - -import httpx - -from app.core import RequestError, WalletError -from app.utils.wallet import link_wallet - -logger = logging.getLogger(__name__) - - -def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]: - try: - return response.json() - except Exception as exc: - raise RequestError(f"Fragment API returned an unparseable response for '{context}': {exc}") from exc - - -async def execute_transaction_request( - client: httpx.AsyncClient, - headers: dict, - account: dict[str, Any], - tx_data: dict[str, Any], - fragment_hash: str, -) -> dict[str, Any]: - url = f"https://fragment.com/api?hash={fragment_hash}" - - resp = await client.post(url, headers=headers, data=tx_data) - transaction = parse_json_response(resp, tx_data.get("method", "transaction")) - - if transaction.get("need_verify"): - if not await link_wallet(client, headers, account, fragment_hash): - raise WalletError( - "Failed to link your TON wallet to Fragment. " "Make sure the wallet matching your cookies is used." - ) - resp = await client.post(url, headers=headers, data=tx_data) - transaction = parse_json_response(resp, tx_data.get("method", "transaction")) - - return transaction diff --git a/app/utils/decoder.py b/app/utils/decoder.py deleted file mode 100644 index 2d52926..0000000 --- a/app/utils/decoder.py +++ /dev/null @@ -1,36 +0,0 @@ -import base64 -import logging - -from pytoniq_core import Cell - -logger = logging.getLogger(__name__) - - -# OLD decoder (manual base64 + regex, kept for reference): -# -# import re, string -# def clean_decode(payload: str) -> str: -# s = re.sub(r'[^A-Za-z0-9+/=]', '', payload.strip()) -# s += '=' * (-len(s) % 4) -# text = base64.b64decode(s).decode('utf-8', errors='ignore') -# text = ''.join(c for c in text if c in string.printable or c.isspace()) -# match = re.search(r'([0-9]*\s*Telegram .*?Ref#[A-Za-z0-9]+)', text, re.S) -# return match.group(1).strip() if match else text.strip() - - -def clean_decode(payload: str) -> str: - # Pad and decode base64 → BOC bytes - s = payload.strip() - if not s: - return "" - s += "=" * (-len(s) % 4) - boc = base64.b64decode(s) - - # Parse BOC cell and read snake-encoded text (skipping 32-bit op prefix) - cell = Cell.one_from_boc(boc) - sl = cell.begin_parse() - sl.load_uint(32) # op code — always 0 for text comment - result = sl.load_snake_string().strip() - - logger.debug("Payload: %s -> %s", payload, result.replace("\n", " ")) - return result diff --git a/app/utils/hash.py b/app/utils/hash.py deleted file mode 100644 index bf4f380..0000000 --- a/app/utils/hash.py +++ /dev/null @@ -1,57 +0,0 @@ -import logging -import re -from typing import Any - -import httpx - -from app.core import HashFetchError - -logger = logging.getLogger(__name__) - - -async def get_fragment_hash( - cookies: dict[str, Any], - headers: dict[str, str], - page_url: str, -) -> str: - # Must look like a real browser navigation — not an XHR — otherwise Fragment - # returns JSON (no hash in it) instead of full HTML. - page_headers = { - k: v - for k, v in headers.items() - if k - not in ( - "accept", - "accept-encoding", - "content-type", - "x-requested-with", - "x-aj-referer", - ) - } - page_headers.update( - { - "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "referer": "https://fragment.com/", - "sec-fetch-dest": "document", - "sec-fetch-mode": "navigate", - "upgrade-insecure-requests": "1", - } - ) - - async with httpx.AsyncClient(cookies=cookies) as client: - response = await client.get(page_url, headers=page_headers) - - if response.status_code != 200: - raise HashFetchError( - f"Fragment returned HTTP {response.status_code} for {page_url}. " - "Check that your cookies are valid and not expired." - ) - - match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text) - if not match: - raise HashFetchError( - f"Fragment hash not found in the page source of {page_url}. " - "The page structure may have changed or you are not logged in." - ) - - return match.group(1) diff --git a/app/utils/wallet.py b/app/utils/wallet.py deleted file mode 100644 index 96ca4f4..0000000 --- a/app/utils/wallet.py +++ /dev/null @@ -1,107 +0,0 @@ -import base64 -import json -import logging -from typing import Any - -import httpx -from tonutils.clients import TonapiClient -from tonutils.types import NetworkGlobalID - -from app.core import DEVICE, WALLET_CLASSES, TransactionError, WalletError, config -from app.utils.decoder import clean_decode - -logger = logging.getLogger(__name__) - - -def initialize_ton_client() -> TonapiClient: - return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY) - - -async def process_transaction(transaction_data: dict) -> str: - logger.debug("transaction_data: %s", transaction_data) - - if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]: - raise TransactionError( - "Fragment returned an invalid transaction payload. " - "The API response is missing expected 'transaction.messages' data." - ) - - # TODO: Investigate 406 'inbound external message rejected before smart-contract execution'. - # This happens when the previous transaction's seqno hasn't been confirmed on-chain yet, - # causing the wallet contract to reject the new message. - async with initialize_ton_client() as client: - wallet_cls = WALLET_CLASSES[config.WALLET_VERSION] - wallet, _, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED) - - # Check balance before broadcasting - try: - await wallet.refresh() - balance_ton = wallet.balance / 1_000_000_000 - if balance_ton < 0.056: - raise WalletError(f"TON wallet balance is too low: {balance_ton:.2f} TON. " "Minimum required is 0.056 TON.") - except WalletError: - raise - except Exception as exc: - raise WalletError(f"Wallet balance check failed: {exc}") from exc - - try: - message = transaction_data["transaction"]["messages"][0] - payload = clean_decode(message["payload"]) - - result = await wallet.transfer( - destination=message["address"], - amount=int(message["amount"]), # nanotons, not TON - body=payload, - ) - tx_hash = result.normalized_hash - return tx_hash - except (WalletError, TransactionError): - raise - except Exception as exc: - raise TransactionError(f"Transaction broadcast failed: {exc}") from exc - - -async def get_account_info() -> dict[str, Any]: - async with initialize_ton_client() as client: - try: - wallet_cls = WALLET_CLASSES[config.WALLET_VERSION] - wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED) - boc = wallet.state_init.serialize().to_boc() - return { - "address": wallet.address.to_str(False, False), - "publicKey": pub_key.as_hex, - "chain": "-239", - "walletStateInit": base64.b64encode(boc).decode(), - } - except Exception as exc: - raise WalletError(f"Failed to retrieve wallet account info: {exc}") from exc - - -async def link_wallet( - client: httpx.AsyncClient, - headers: dict, - account: dict[str, Any], - fragment_hash: str, -) -> bool: - resp = await client.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=headers, - data={ - "account": json.dumps(account), - "device": DEVICE, - "method": "linkWallet", - }, - ) - result = resp.json() - - if result.get("ok"): - return True - - if "transaction" in result: - try: - await process_transaction(result) - return True - except (TransactionError, WalletError): - return False - - return False diff --git a/cookies.example.json b/cookies.example.json deleted file mode 100644 index 7e8b0d5..0000000 --- a/cookies.example.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "stel_ssid": "", - "stel_dt": "", - "stel_token": "", - "stel_ton_token": "" -} diff --git a/fragmentapi/__init__.py b/fragmentapi/__init__.py new file mode 100644 index 0000000..14d0e51 --- /dev/null +++ b/fragmentapi/__init__.py @@ -0,0 +1,44 @@ +# Copyright (c) 2025 bohd4nx +# +# This source code is licensed under the MIT License found in the +# LICENSE file in the root directory of this source tree. + +from fragmentapi.client import FragmentClient +from fragmentapi.types import ( + AdsTopupResult, + ClientError, + ConfigError, + CookiesError, + FragmentAPIError, + FragmentError, + HashFetchError, + OperationError, + PremiumResult, + RequestError, + StarsResult, + TransactionError, + UnexpectedError, + UserNotFoundError, + VerificationError, + WalletError, +) + +__all__ = [ + "FragmentClient", + "AdsTopupResult", + "PremiumResult", + "StarsResult", + "ClientError", + "ConfigError", + "CookiesError", + "FragmentAPIError", + "FragmentError", + "HashFetchError", + "OperationError", + "RequestError", + "TransactionError", + "UnexpectedError", + "UserNotFoundError", + "VerificationError", + "WalletError", +] diff --git a/fragmentapi/client.py b/fragmentapi/client.py new file mode 100644 index 0000000..cc93cee --- /dev/null +++ b/fragmentapi/client.py @@ -0,0 +1,112 @@ +import json + +from fragmentapi.methods.premium import gift_premium +from fragmentapi.methods.stars import gift_stars +from fragmentapi.methods.ton import topup_ton +from fragmentapi.types import ( + REQUIRED_COOKIE_KEYS, + SUPPORTED_WALLET_VERSIONS, + AdsTopupResult, + ConfigError, + CookiesError, + PremiumResult, + StarsResult, + WalletVersion, +) + + +class FragmentClient: + """ + Client for the Fragment.com API. + + Args: + seed: 24-word mnemonic phrase for the TON wallet. + api_key: Tonapi API key — get one at https://tonconsole.com. + cookies: Fragment session cookies as a dict or JSON string. + wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default). + + Raises: + ConfigError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid. + CookiesError: If ``cookies`` cannot be parsed or are missing required keys. + + Example:: + + client = FragmentClient( + seed="word1 word2 ...", + api_key="AAABBB...", + cookies={"stel_ssid": "...", "stel_dt": "...", ...}, + ) + result = await client.gift_premium("@username", months=6) + print(result.transaction_id) + """ + + def __init__( + self, + seed: str, + api_key: str, + cookies: dict | str, + wallet_version: str = "V5R1", + ) -> None: + missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()] + if missing: + raise ConfigError(ConfigError.MISSING_VARS.format(keys=", ".join(missing))) + + if isinstance(cookies, str): + try: + cookies = json.loads(cookies) + except Exception as exc: + raise CookiesError(CookiesError.READ_FAILED.format(exc=exc)) from exc + + missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()] + if missing_keys: + raise CookiesError(CookiesError.MISSING_KEYS.format(keys=", ".join(missing_keys))) + + version = wallet_version.strip().upper() + if version not in SUPPORTED_WALLET_VERSIONS: + raise ConfigError( + ConfigError.UNSUPPORTED_VERSION.format(version=version, supported=", ".join(sorted(SUPPORTED_WALLET_VERSIONS))) + ) + + self.seed: str = seed.strip() + self.api_key: str = api_key.strip() + self.cookies: dict = cookies + self.wallet_version: WalletVersion = version # type: ignore[assignment] + + async def gift_premium(self, username: str, months: int, show_sender: bool = True) -> PremiumResult: + """Gift Telegram Premium to a user. + + Args: + username: Recipient's Telegram username (with or without ``@``). + months: Duration — ``3``, ``6``, or ``12``. + show_sender: Show your name as the gift sender. Defaults to ``True``. + + Returns: + :class:`PremiumResult` with ``transaction_id``, ``username``, ``months``, ``timestamp``. + """ + return await gift_premium(self, username, months, show_sender) + + async def gift_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult: + """Gift Telegram Stars to a user. + + Args: + username: Recipient's Telegram username (with or without ``@``). + amount: Number of stars — integer from ``50`` to ``1 000 000``. + show_sender: Show your name as the gift sender. Defaults to ``True``. + + Returns: + :class:`StarsResult` with ``transaction_id``, ``username``, ``stars``, ``timestamp``. + """ + return await gift_stars(self, username, amount, show_sender) + + async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: + """Top up Telegram Ads balance with TON. + + Args: + username: Ads account username (with or without ``@``). + amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. + show_sender: Show your name as the sender. Defaults to ``True``. + + Returns: + :class:`AdsTopupResult` with ``transaction_id``, ``username``, ``amount``, ``timestamp``. + """ + return await topup_ton(self, username, amount, show_sender) diff --git a/fragmentapi/methods/__init__.py b/fragmentapi/methods/__init__.py new file mode 100644 index 0000000..b0be4ac --- /dev/null +++ b/fragmentapi/methods/__init__.py @@ -0,0 +1,5 @@ +from fragmentapi.methods.premium import gift_premium +from fragmentapi.methods.stars import gift_stars +from fragmentapi.methods.ton import topup_ton + +__all__ = ["gift_premium", "gift_stars", "topup_ton"] diff --git a/fragmentapi/methods/premium.py b/fragmentapi/methods/premium.py new file mode 100644 index 0000000..b409d63 --- /dev/null +++ b/fragmentapi/methods/premium.py @@ -0,0 +1,119 @@ +import json +import time +from typing import TYPE_CHECKING + +import httpx + +from fragmentapi.types import ( + BASE_HEADERS, + DEVICE, + PREMIUM_PAGE, + ConfigError, + FragmentAPIError, + FragmentError, + PremiumResult, + UnexpectedError, + UserNotFoundError, +) +from fragmentapi.utils import ( + execute_transaction_request, + get_account_info, + get_fragment_hash, + parse_json_response, + process_transaction, +) + +if TYPE_CHECKING: + from fragmentapi.client import FragmentClient + +# Page-specific headers +HEADERS: dict[str, str] = { + **BASE_HEADERS, + "referer": PREMIUM_PAGE, + "x-aj-referer": PREMIUM_PAGE, +} + + +async def _search_recipient( + session: httpx.AsyncClient, + fragment_hash: str, + username: str, + months: int, +) -> str: + resp = await session.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, + data={ + "query": username, + "months": months, + "method": "searchPremiumGiftRecipient", + }, + ) + result = parse_json_response(resp, "searchPremiumGiftRecipient") + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) + return recipient + + +async def _init_request( + session: httpx.AsyncClient, + fragment_hash: str, + recipient: str, + months: int, +) -> str: + await session.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, + data={ + "mode": "new", + "lv": "false", + "dh": str(int(time.time())), + "method": "updatePremiumState", + }, + ) + resp = await session.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, + data={ + "recipient": recipient, + "months": months, + "method": "initGiftPremiumRequest", + }, + ) + result = parse_json_response(resp, "initGiftPremiumRequest") + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase")) + return req_id + + +async def gift_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult: + if months not in (3, 6, 12): + raise ConfigError(ConfigError.INVALID_MONTHS) + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_PAGE) + account = await get_account_info(client) + + async with httpx.AsyncClient(cookies=client.cookies) as session: + recipient = await _search_recipient(session, fragment_hash, username, months) + req_id = await _init_request(session, fragment_hash, recipient, months) + + tx_data = { + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "show_sender": int(show_sender), + "method": "getGiftPremiumLink", + } + transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + + tx_hash = await process_transaction(client, transaction) + return PremiumResult(transaction_id=tx_hash, username=username, months=months) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/fragmentapi/methods/stars.py b/fragmentapi/methods/stars.py new file mode 100644 index 0000000..36bd271 --- /dev/null +++ b/fragmentapi/methods/stars.py @@ -0,0 +1,103 @@ +import json +from typing import TYPE_CHECKING + +import httpx + +from fragmentapi.types import ( + BASE_HEADERS, + DEVICE, + STARS_PAGE, + ConfigError, + FragmentAPIError, + FragmentError, + StarsResult, + UnexpectedError, + UserNotFoundError, +) +from fragmentapi.utils import ( + execute_transaction_request, + get_account_info, + get_fragment_hash, + parse_json_response, + process_transaction, +) + +if TYPE_CHECKING: + from fragmentapi.client import FragmentClient + +# Page-specific headers +HEADERS: dict[str, str] = { + **BASE_HEADERS, + "referer": STARS_PAGE, + "x-aj-referer": STARS_PAGE, +} + + +async def _search_recipient( + session: httpx.AsyncClient, + fragment_hash: str, + username: str, +) -> str: + resp = await session.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, + data={"query": username, "quantity": "", "method": "searchStarsRecipient"}, + ) + result = parse_json_response(resp, "searchStarsRecipient") + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) + return recipient + + +async def _init_request( + session: httpx.AsyncClient, + fragment_hash: str, + recipient: str, + amount: int, +) -> str: + resp = await session.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, + data={ + "recipient": recipient, + "quantity": amount, + "method": "initBuyStarsRequest", + }, + ) + result = parse_json_response(resp, "initBuyStarsRequest") + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase")) + return req_id + + +async def gift_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult: + if not isinstance(amount, int) or not (50 <= amount <= 1_000_000): + raise ConfigError(ConfigError.INVALID_STARS_AMOUNT) + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_PAGE) + account = await get_account_info(client) + + async with httpx.AsyncClient(cookies=client.cookies) as session: + recipient = await _search_recipient(session, fragment_hash, username) + req_id = await _init_request(session, fragment_hash, recipient, amount) + + tx_data = { + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "show_sender": int(show_sender), + "method": "getBuyStarsLink", + } + transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + + tx_hash = await process_transaction(client, transaction) + return StarsResult(transaction_id=tx_hash, username=username, stars=amount) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/fragmentapi/methods/ton.py b/fragmentapi/methods/ton.py new file mode 100644 index 0000000..325d8e0 --- /dev/null +++ b/fragmentapi/methods/ton.py @@ -0,0 +1,108 @@ +import json +from typing import TYPE_CHECKING + +import httpx + +from fragmentapi.types import ( + BASE_HEADERS, + DEVICE, + TON_PAGE, + AdsTopupResult, + ConfigError, + FragmentAPIError, + FragmentError, + UnexpectedError, + UserNotFoundError, +) +from fragmentapi.utils import ( + execute_transaction_request, + get_account_info, + get_fragment_hash, + parse_json_response, + process_transaction, +) + +if TYPE_CHECKING: + from fragmentapi.client import FragmentClient + +# Page-specific headers +HEADERS: dict[str, str] = { + **BASE_HEADERS, + "referer": TON_PAGE, + "x-aj-referer": TON_PAGE, +} + + +async def _search_recipient( + session: httpx.AsyncClient, + fragment_hash: str, + username: str, +) -> str: + await session.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, + data={"mode": "new", "method": "updateAdsTopupState"}, + ) + resp = await session.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, + data={"query": username, "method": "searchAdsTopupRecipient"}, + ) + result = parse_json_response(resp, "searchAdsTopupRecipient") + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) + return recipient + + +async def _init_request( + session: httpx.AsyncClient, + fragment_hash: str, + recipient: str, + amount: int, +) -> str: + resp = await session.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, + data={ + "recipient": recipient, + "amount": amount, + "method": "initAdsTopupRequest", + }, + ) + result = parse_json_response(resp, "initAdsTopupRequest") + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup")) + return req_id + + +async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: + if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): + raise ConfigError(ConfigError.INVALID_TON_AMOUNT) + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, TON_PAGE) + account = await get_account_info(client) + + async with httpx.AsyncClient(cookies=client.cookies) as session: + recipient = await _search_recipient(session, fragment_hash, username) + req_id = await _init_request(session, fragment_hash, recipient, amount) + + tx_data = { + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "show_sender": int(show_sender), + "method": "getAdsTopupLink", + } + transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + + tx_hash = await process_transaction(client, transaction) + return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/fragmentapi/types/__init__.py b/fragmentapi/types/__init__.py new file mode 100644 index 0000000..addcddd --- /dev/null +++ b/fragmentapi/types/__init__.py @@ -0,0 +1,59 @@ +from fragmentapi.types.constants import ( + BASE_HEADERS, + DEVICE, + PREMIUM_PAGE, + REQUIRED_COOKIE_KEYS, + STARS_PAGE, + SUPPORTED_WALLET_VERSIONS, + TON_PAGE, + WALLET_CLASSES, + WalletVersion, +) +from fragmentapi.types.exceptions import ( + ClientError, + ConfigError, + CookiesError, + FragmentAPIError, + FragmentError, + HashFetchError, + OperationError, + RequestError, + TransactionError, + UnexpectedError, + UserNotFoundError, + VerificationError, + WalletError, +) +from fragmentapi.types.results import AdsTopupResult, PremiumResult, StarsResult + +__all__ = [ + # constants + "BASE_HEADERS", + "DEVICE", + "PREMIUM_PAGE", + "REQUIRED_COOKIE_KEYS", + "STARS_PAGE", + "SUPPORTED_WALLET_VERSIONS", + "TON_PAGE", + "WALLET_CLASSES", + "WalletVersion", + # client exceptions + "ClientError", + "ConfigError", + "CookiesError", + # fragment exceptions + "FragmentAPIError", + "FragmentError", + "HashFetchError", + "OperationError", + "RequestError", + "TransactionError", + "UnexpectedError", + "UserNotFoundError", + "VerificationError", + "WalletError", + # result types + "AdsTopupResult", + "PremiumResult", + "StarsResult", +] diff --git a/app/core/constants.py b/fragmentapi/types/constants.py similarity index 90% rename from app/core/constants.py rename to fragmentapi/types/constants.py index 243edb5..073d1e7 100644 --- a/app/core/constants.py +++ b/fragmentapi/types/constants.py @@ -10,10 +10,13 @@ SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion)) # Wallet class map — used to resolve the correct contract from WALLET_VERSION WALLET_CLASSES: dict[str, type] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1} +# Required Fragment session cookie keys +REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") + # Fragment page URLs STARS_PAGE: str = "https://fragment.com/stars/buy" PREMIUM_PAGE: str = "https://fragment.com/premium/gift" -ADS_PAGE: str = "https://fragment.com/ads/topup" +TON_PAGE: str = "https://fragment.com/ads/topup" # Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. DEVICE: str = json.dumps( diff --git a/fragmentapi/types/exceptions.py b/fragmentapi/types/exceptions.py new file mode 100644 index 0000000..b19215f --- /dev/null +++ b/fragmentapi/types/exceptions.py @@ -0,0 +1,106 @@ +class FragmentError(Exception): + """Base exception for all fragmentapi library errors.""" + + +class ClientError(FragmentError): + """Raised for client configuration and setup issues (bad params, invalid cookies).""" + + +class ConfigError(ClientError): + """Raised when required client parameters are missing or invalid.""" + + MISSING_VARS = "Missing required parameter(s): {keys}." + UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}." + INVALID_MONTHS = "Invalid duration. Choose 3, 6, or 12 months." + INVALID_STARS_AMOUNT = "Amount must be an integer between 50 and 1 000 000 stars." + INVALID_TON_AMOUNT = "Amount must be an integer between 1 and 1 000 000 000 TON." + + +class CookiesError(ClientError): + """Raised when cookies are unreadable or missing required fields.""" + + READ_FAILED = "Failed to parse cookies: {exc}" + MISSING_KEYS = ( + "Cookies are missing or have empty values for: {keys}. " "Open Fragment.com in your browser and copy fresh cookies." + ) + + +class FragmentAPIError(FragmentError): + """Raised for errors returned by Fragment's API responses.""" + + NO_REQUEST_ID = ( + "Fragment did not return a request ID for '{context}'. " "The session may have expired — refresh your cookies." + ) + + +class HashFetchError(FragmentAPIError): + """Raised when the Fragment API hash cannot be fetched from the page.""" + + BAD_STATUS = "Fragment returned HTTP {status} for {url}. " "Check that your cookies are valid and not expired." + NOT_FOUND = ( + "Fragment hash not found in the page source of {url}. " "The page structure may have changed or you are not logged in." + ) + + +class UserNotFoundError(FragmentAPIError): + """Raised when the target Telegram user is not found on Fragment.""" + + NOT_FOUND = ( + "Telegram user '{username}' was not found on Fragment. " "Make sure the username is correct and the account exists." + ) + + +class TransactionError(FragmentAPIError): + """Raised when a TON transaction fails to build or broadcast.""" + + INVALID_PAYLOAD = ( + "Fragment returned an invalid transaction payload. " "The API response is missing expected 'transaction.messages' data." + ) + BROADCAST_FAILED = "Transaction broadcast failed: {exc}" + + +class RequestError(FragmentAPIError): + """Raised when a Fragment API response cannot be parsed.""" + + UNPARSEABLE = "Fragment API returned an unparseable response for '{context}': {exc}" + + +class VerificationError(FragmentAPIError): + """Raised when Fragment requires KYC verification before proceeding.""" + + KYC_REQUIRED = "Fragment requires identity (KYC) verification. " "Complete it at https://fragment.com/my/profile and retry." + + +class OperationError(FragmentError): + """Raised for runtime operation failures unrelated to Fragment's API.""" + + +class WalletError(OperationError): + """Raised for TON wallet issues (connection, balance, account info).""" + + LOW_BALANCE = "TON wallet balance is too low: {balance:.2f} TON. Minimum required is 0.056 TON." + BALANCE_CHECK_FAILED = "Wallet balance check failed: {exc}" + ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info: {exc}" + + +class UnexpectedError(OperationError): + """Raised when an unexpected error occurs during an API call.""" + + UNEXPECTED = "An unexpected error occurred: {exc}" + + +__all__ = [ + "FragmentError", + "ClientError", + "ConfigError", + "CookiesError", + "FragmentAPIError", + "HashFetchError", + "UserNotFoundError", + "TransactionError", + "RequestError", + "VerificationError", + "OperationError", + "WalletError", + "UnexpectedError", +] diff --git a/fragmentapi/types/results.py b/fragmentapi/types/results.py new file mode 100644 index 0000000..234f7cc --- /dev/null +++ b/fragmentapi/types/results.py @@ -0,0 +1,34 @@ +import time +from dataclasses import dataclass, field + +__all__ = ["AdsTopupResult", "PremiumResult", "StarsResult"] + + +@dataclass +class PremiumResult: + """Result of a successful Telegram Premium gift.""" + + transaction_id: str + username: str + months: int + timestamp: int = field(default_factory=lambda: int(time.time())) + + +@dataclass +class StarsResult: + """Result of a successful Telegram Stars purchase.""" + + transaction_id: str + username: str + stars: int + timestamp: int = field(default_factory=lambda: int(time.time())) + + +@dataclass +class AdsTopupResult: + """Result of a successful Telegram Ads balance top-up.""" + + transaction_id: str + username: str + amount: int + timestamp: int = field(default_factory=lambda: int(time.time())) diff --git a/fragmentapi/utils/__init__.py b/fragmentapi/utils/__init__.py new file mode 100644 index 0000000..c570db8 --- /dev/null +++ b/fragmentapi/utils/__init__.py @@ -0,0 +1,16 @@ +from fragmentapi.utils.client import ( + execute_transaction_request, + get_fragment_hash, + parse_json_response, +) +from fragmentapi.utils.decoder import clean_decode +from fragmentapi.utils.wallet import get_account_info, process_transaction + +__all__ = [ + "clean_decode", + "execute_transaction_request", + "get_account_info", + "get_fragment_hash", + "parse_json_response", + "process_transaction", +] diff --git a/fragmentapi/utils/client.py b/fragmentapi/utils/client.py new file mode 100644 index 0000000..53be7bc --- /dev/null +++ b/fragmentapi/utils/client.py @@ -0,0 +1,107 @@ +import re +from typing import Any + +import httpx + +from fragmentapi.types import HashFetchError, RequestError, VerificationError + + +async def get_fragment_hash( + cookies: dict[str, Any], + headers: dict[str, str], + page_url: str, +) -> str: + """Fetch the API hash from a Fragment page. + + Fragment embeds a short-lived hash in each page's HTML that must be + included in every subsequent API request. This function loads the page + as a real browser navigation (not XHR) so Fragment returns full HTML. + + Args: + cookies: Active Fragment session cookies. + headers: Base headers for the relevant Fragment page. + page_url: URL of the Fragment page to fetch the hash from. + + Returns: + Lowercase hex hash string. + + Raises: + HashFetchError: If the page returns a non-200 status or the hash + is not found in the response HTML. + """ + page_headers = { + k: v + for k, v in headers.items() + if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer") + } + page_headers.update( + { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": "https://fragment.com/", + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "upgrade-insecure-requests": "1", + } + ) + + async with httpx.AsyncClient(cookies=cookies) as session: + response = await session.get(page_url, headers=page_headers) + + if response.status_code != 200: + raise HashFetchError(HashFetchError.BAD_STATUS.format(status=response.status_code, url=page_url)) + + match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text) + if not match: + raise HashFetchError(HashFetchError.NOT_FOUND.format(url=page_url)) + + return match.group(1) + + +def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]: + """Parse a Fragment API JSON response. + + Args: + response: The HTTP response object. + context: Human-readable name of the API method, used in error messages. + + Returns: + Parsed response as a dict. + + Raises: + RequestError: If the response body cannot be decoded as JSON. + """ + try: + return response.json() + except Exception as exc: + raise RequestError(RequestError.UNPARSEABLE.format(context=context, exc=exc)) from exc + + +async def execute_transaction_request( + session: httpx.AsyncClient, + headers: dict, + tx_data: dict[str, Any], + fragment_hash: str, +) -> dict[str, Any]: + """Post a transaction request to the Fragment API. + + Args: + session: Active httpx session with Fragment cookies. + headers: Page-specific HTTP headers. + tx_data: Form data payload for the API method. + fragment_hash: Short-lived hash from the Fragment page. + + Returns: + Parsed API response dict containing transaction data. + + Raises: + VerificationError: If Fragment requires KYC verification. + RequestError: If the response cannot be parsed. + """ + url = f"https://fragment.com/api?hash={fragment_hash}" + resp = await session.post(url, headers=headers, data=tx_data) + transaction = parse_json_response(resp, tx_data.get("method", "transaction")) + + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) + + return transaction diff --git a/fragmentapi/utils/decoder.py b/fragmentapi/utils/decoder.py new file mode 100644 index 0000000..43a9e75 --- /dev/null +++ b/fragmentapi/utils/decoder.py @@ -0,0 +1,20 @@ +import base64 + +from pytoniq_core import Cell + +from fragmentapi.types import RequestError + + +def clean_decode(payload: str) -> str: + s = payload.strip() + if not s: + return "" + s += "=" * (-len(s) % 4) + try: + boc = base64.b64decode(s) + cell = Cell.one_from_boc(boc) + sl = cell.begin_parse() + sl.load_uint(32) # op code — always 0 for text comment + return sl.load_snake_string().strip() + except Exception as exc: + raise RequestError(RequestError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc diff --git a/fragmentapi/utils/wallet.py b/fragmentapi/utils/wallet.py new file mode 100644 index 0000000..e852bdc --- /dev/null +++ b/fragmentapi/utils/wallet.py @@ -0,0 +1,69 @@ +import base64 +from typing import TYPE_CHECKING, Any + +from tonutils.clients import TonapiClient +from tonutils.types import NetworkGlobalID + +from fragmentapi.types import WALLET_CLASSES, TransactionError, WalletError +from fragmentapi.utils.decoder import clean_decode + +if TYPE_CHECKING: + from fragmentapi.client import FragmentClient + + +def _init_ton_client(client: "FragmentClient") -> TonapiClient: + return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) + + +async def process_transaction(client: "FragmentClient", transaction_data: dict) -> str: + if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]: + raise TransactionError(TransactionError.INVALID_PAYLOAD) + + # TODO: Investigate 406 'inbound external message rejected before smart-contract execution'. + # This happens when the previous transaction's seqno hasn't been confirmed on-chain yet, + # causing the wallet contract to reject the new message. + async with _init_ton_client(client) as ton: + wallet_cls = WALLET_CLASSES[client.wallet_version] + wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) + + # Check balance before broadcasting + try: + await wallet.refresh() + balance_ton = wallet.balance / 1_000_000_000 + if balance_ton < 0.056: + raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton)) + except WalletError: + raise + except Exception as exc: + raise WalletError(WalletError.BALANCE_CHECK_FAILED.format(exc=exc)) from exc + + try: + message = transaction_data["transaction"]["messages"][0] + payload = clean_decode(message["payload"]) + + result = await wallet.transfer( + destination=message["address"], + amount=int(message["amount"]), # nanotons, not TON + body=payload, + ) + return result.normalized_hash + except (WalletError, TransactionError): + raise + except Exception as exc: + raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc + + +async def get_account_info(client: "FragmentClient") -> dict[str, Any]: + async with _init_ton_client(client) as ton: + try: + wallet_cls = WALLET_CLASSES[client.wallet_version] + wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) + boc = wallet.state_init.serialize().to_boc() + return { + "address": wallet.address.to_str(False, False), + "publicKey": pub_key.as_hex, + "chain": "-239", + "walletStateInit": base64.b64encode(boc).decode(), + } + except Exception as exc: + raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc diff --git a/main.py b/main.py deleted file mode 100644 index 4c85964..0000000 --- a/main.py +++ /dev/null @@ -1,66 +0,0 @@ -import asyncio -import logging - -from app.core import setup_logging -from app.methods import buy_premium, buy_stars, topup_ton - -logger = logging.getLogger(__name__) - - -async def topup_ton_example(): - logger.info("Starting TON topup example") - - # @bohd4nx - target username, 100 - TON amount (integer 1-1000000000 (one billion)) - # show_sender=True — recipient sees who sent the topup - result = await topup_ton("@bohd4nx", 100, show_sender=True) - - if result["success"]: - pass # Transaction successful, details are logged in the method - else: - logger.error(f"TON topup failed: {result['error']}") - - -async def buy_premium_example(): - logger.info("Starting Premium purchase example") - - # @bohd4nx - target username, 12 - months duration (3, 6, or 12 only) - # show_sender=True — recipient sees who gifted the Premium - result = await buy_premium("@bohd4nx", 12, show_sender=True) - - if result["success"]: - pass # Transaction successful, details are logged in the method - else: - logger.error(f"Premium purchase failed: {result['error']}") - - -async def buy_stars_example(): - logger.info("Starting Stars purchase example") - - # @bohd4nx - target username, 1000000 - stars amount (integer 50-1000000 (one million)) - # show_sender=True — recipient sees who sent the Stars - result = await buy_stars("@bohd4nx", 1000000, show_sender=True) - - if result["success"]: - pass # Transaction successful, details are logged in the method - else: - logger.error(f"Stars purchase failed: {result['error']}") - - -async def main(): - setup_logging() - logger.info("Starting Fragment API by @bohd4nx - examples") - - await topup_ton_example() - await buy_premium_example() - await buy_stars_example() - - logger.info("All examples completed") - - -if __name__ == "__main__": - logger.info("Fragment API by @bohd4nx - Usage Examples") - logger.info("Supported username formats: @username, username") - logger.info("Limits: TON minimum 1, Premium 3/6/12 months, Stars minimum 50") - logger.info("Setup: Copy .env.example to .env and fill all fields") - - asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 48cb023..3a407fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,43 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "fragmentapi" +version = "2026.1.0" +description = "Python library for the Fragment.com API — gift Telegram Stars, Premium, and top up TON Ads balance." +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.12" +authors = [{ name = "bohd4nx", url = "https://github.com/bohd4nx" }] +keywords = ["fragment", "telegram", "ton", "stars", "premium", "crypto", "blockchain"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + "Framework :: AsyncIO", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Internet", + "Topic :: Office/Business :: Financial", + "Typing :: Typed", +] +dependencies = [ + "httpx==0.28.1", + "tonutils[pytoniq]==2.0.0", +] + +[project.urls] +Homepage = "https://github.com/bohd4nx/FragmentAPI" +Repository = "https://github.com/bohd4nx/FragmentAPI" +Issues = "https://github.com/bohd4nx/FragmentAPI/issues" + +[tool.hatch.build.targets.wheel] +packages = ["fragmentapi"] + [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["[0-9][0-9][0-9]_test_*.py"] diff --git a/requirements.txt b/requirements.txt index ab531a7..5d930d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,2 @@ -python-dotenv==1.2.2 -asyncio==4.0.0 httpx==0.28.1 tonutils[pytoniq]==2.0.0 diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py index 408de53..d968e5d 100644 --- a/tests/001_test_decode.py +++ b/tests/001_test_decode.py @@ -1,11 +1,11 @@ -"""Tests for clean_decode() — BOC-encoded Fragment payloads decode to -human-readable UTF-8 with the Telegram label and Ref# intact.""" +"""Tests for clean_decode() — BOC-encoded Fragment payloads decode to UTF-8.""" import re import pytest -from app.utils.decoder import clean_decode +from fragmentapi.types import RequestError +from fragmentapi.utils.decoder import clean_decode PAYLOADS = [ pytest.param( @@ -24,12 +24,17 @@ PAYLOADS = [ @pytest.mark.parametrize("payload", PAYLOADS) -def test_payload(payload: str) -> None: +def test_decode_payload(payload: str) -> None: result = clean_decode(payload) assert "Telegram" in result assert re.search(r"Ref#[A-Za-z0-9]+", result), f"no Ref# in {result!r}" - assert all(ord(c) <= 127 for c in result), f"non-ASCII chars in {result!r}" + assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}" -def test_empty_input_returns_string() -> None: - assert isinstance(clean_decode(""), str) +def test_empty_payload_returns_empty_string() -> None: + assert clean_decode("") == "" + + +def test_invalid_payload_raises_request_error() -> None: + with pytest.raises(RequestError): + clean_decode("!!!not-valid-base64!!!") diff --git a/tests/002_test_client.py b/tests/002_test_client.py new file mode 100644 index 0000000..4aee0d0 --- /dev/null +++ b/tests/002_test_client.py @@ -0,0 +1,81 @@ +"""Unit tests for FragmentClient — init validation and cookie parsing (no network calls).""" + +import json + +import pytest + +from fragmentapi import FragmentClient +from fragmentapi.types import ConfigError, CookiesError + +VALID_SEED = "abandon " * 23 + "about" +VALID_API_KEY = "test_api_key" +VALID_COOKIES = { + "stel_ssid": "x", + "stel_dt": "x", + "stel_token": "x", + "stel_ton_token": "x", +} + + +def test_valid_init() -> None: + client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) + assert client.seed == VALID_SEED.strip() + assert client.api_key == VALID_API_KEY + assert client.wallet_version == "V5R1" + + +def test_wallet_version_v4r2() -> None: + client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V4R2") + assert client.wallet_version == "V4R2" + + +def test_wallet_version_is_case_insensitive() -> None: + client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="v5r1") + assert client.wallet_version == "V5R1" + + +def test_missing_seed_raises() -> None: + with pytest.raises(ConfigError): + FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES) + + +def test_whitespace_only_seed_raises() -> None: + with pytest.raises(ConfigError): + FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES) + + +def test_missing_api_key_raises() -> None: + with pytest.raises(ConfigError): + FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES) + + +def test_unsupported_wallet_version_raises() -> None: + with pytest.raises(ConfigError): + FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V3R2") + + +def test_cookies_as_json_string() -> None: + client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=json.dumps(VALID_COOKIES)) + assert client.cookies == VALID_COOKIES + + +def test_invalid_cookies_json_raises() -> None: + with pytest.raises(CookiesError): + FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies="{not valid json}") + + +def test_missing_cookie_key_raises() -> None: + with pytest.raises(CookiesError): + FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies={"stel_ssid": "x"}) + + +def test_empty_cookie_value_raises() -> None: + bad = {**VALID_COOKIES, "stel_token": ""} + with pytest.raises(CookiesError): + FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad) + + +def test_whitespace_cookie_value_raises() -> None: + bad = {**VALID_COOKIES, "stel_ton_token": " "} + with pytest.raises(CookiesError): + FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad) diff --git a/tests/002_test_hash.py b/tests/003_test_hash.py similarity index 82% rename from tests/002_test_hash.py rename to tests/003_test_hash.py index b1b50ec..0140f09 100644 --- a/tests/002_test_hash.py +++ b/tests/003_test_hash.py @@ -5,8 +5,8 @@ import re import pytest -from app.core.constants import BASE_HEADERS, STARS_PAGE -from app.utils.hash import get_fragment_hash +from fragmentapi.types import BASE_HEADERS, STARS_PAGE +from fragmentapi.utils import get_fragment_hash @pytest.mark.asyncio diff --git a/tests/conftest.py b/tests/conftest.py index ac14129..9b60a88 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,17 @@ -import pytest +import json +from pathlib import Path -from app.core.cookies import load_cookies -from app.core.exceptions import CookiesError +import pytest @pytest.fixture def cookies(): - """Load Fragment cookies; skip the test if they are unavailable.""" + """Load Fragment cookies from cookies.json; skip the test if unavailable.""" + cookies_path = Path(__file__).resolve().parents[1] / "cookies.json" + if not cookies_path.exists(): + pytest.skip("cookies.json not found") try: - return load_cookies() - except CookiesError as exc: + with cookies_path.open("r", encoding="utf-8") as f: + return json.load(f) + except Exception as exc: pytest.skip(f"Cookies unavailable — {exc}")