# v2 git bundle
ed88069e60f7f2f352b1c66264f084a1e77bcd21 refs/heads/bundle
ed88069e60f7f2f352b1c66264f084a1e77bcd21 HEAD

PACK      x7tree 333d6b534362bc8cc56b8d2ab8b3a8956d6a9385
parent 910d29d5b9cae2f1050f1aba99e9740f60dbb80b
author MythEclipse <asepharyanalm001@gmail.com> 1779069264 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779069264 +0700

fix: assert sendDocument with config.storageChatId to match dynamic mock env in CI
cbx/tree f58a341962cd71da99af41a8e0e2a7ff419b0cfb
parent c0827b09c5734f1a38bb3750bf896403379dd7bb
author MythEclipse <asepharyanalm001@gmail.com> 1779069224 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779069224 +0700

fix: upgrade Bun setup version to latest to support modern lockfile format
NaDx=tree f07cdff798467b586c50c5891e1a6bb3fa0afc03
parent 7f1178d72b510522f79f99d630498b5b17426096
author MythEclipse <asepharyanalm001@gmail.com> 1779068569 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779068569 +0700

fix: make DB tests more robust by checking definition rather than strict module identity
e(xDtree 7dee9e60ce11d243d9ceebee9db7d455973836b8
parent 06a1b38c025ae868a2f1ada6858d1d8623d2770a
parent 1fc408046627586e1906b9f2454c7f07e3f97805
author MythEclipse <asepharyanalm001@gmail.com> 1779068497 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779068497 +0700

Merge branch 'worktree-agent-a7a26e7873ab5b94e'
Taוxetree 633f9563e2e028939a3debfdbfca1573425d1e73
parent 2c1c5ad6383264c0ec97910b56e7a80ea3f19b62
author MythEclipse <asepharyanalm001@gmail.com> 1779068489 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779068489 +0700

ci: add GitHub Actions pipeline for tests, Docker build, and deployment

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
;qxDtree 4ef940d99619d1e7e4e3eb84c0bf4df18dd2e1e0
parent e4c074c81f47bea51fc257596c5949db6b9e6905
parent b41fdb4a887e94217e674d40751f2f409a4d6d1f
author MythEclipse <asepharyanalm001@gmail.com> 1779068460 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779068460 +0700

Merge branch 'worktree-agent-aae37f6f3cccac163'
c=xtree ce8e68d24ba8fa90f5ffae02dcd480bf5a71d3b7
parent d7f5180eab08eefaa82377da156ff7e02da24541
author MythEclipse <asepharyanalm001@gmail.com> 1779068456 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779068456 +0700

docs: add deployment design and implementation plan
kYhx x-tree d3731c514bb8193f08d58f93f8fb65823b5fc23a-h397AF397 +0700

chore: add Dockerfile and dockerignore for production build+:s*x z-tree ec818722ada3fc406688496f7b7d24e7ef841ded-h442AD442 +0700

chore: add docker-compose.yml with Traefik configurations:*xtree 325913e92098ead18af34767e5b5836e27dd9f0a
parent 91986d1d51c2411f144696fa016a0ec50507c872
author MythEclipse <asepharyanalm001@gmail.com> 1779067524 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779067524 +0700

fix: add Bun database migration runner
tQxtree 42d98de0973264cb6d213958261038d2cb55eeed
parent 9aedc59b69772102cafb5b496747df09be8547dc
author MythEclipse <asepharyanalm001@gmail.com> 1779067135 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779067135 +0700

fix: upload binary files to Telegram with source payload
Yxtree 1c38e45dc33cce54ef800aca2d3916b77f26d121
parent 6feca3564af2e2a58cdc567e601140c039ab81e8
author MythEclipse <asepharyanalm001@gmail.com> 1779064995 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779064995 +0700

fix: refine Swagger docs headers and dynamic examples
Y'xtree 68eb596a98a9ff83e3dc5f4c90e338f698690152
parent aae8ebfbcf74594ee2282b238f4de43386bc61ae
author MythEclipse <asepharyanalm001@gmail.com> 1779064767 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779064767 +0700

feat: add Swagger OpenAPI documentation endpoints
iWx;tree abe8cb98ec8b13338f7630edc8d452ce7621caa0
parent 825a8f42cb36e64995cc441eb73f533fbf41d52f
author MythEclipse <asepharyanalm001@gmail.com> 1779064247 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779064247 +0700

chore: update bun.lock to include @biomejs/biome, typescript, and related CLI packages
dxtree 6714e5c8786aaca5e43ffd78c28523aca8f0b70e
parent cf79a1f1955f6a6eb4441fd4a9cc841a3528a1b1
author MythEclipse <asepharyanalm001@gmail.com> 1779064233 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779064233 +0700

chore: add build script and target bun bundle output
gXxtree 1d1c4f213a385bfc81f9f19184966923a26c5be4
parent 3f4e697733039b9b4383d098d97da9a79c8141e8
author MythEclipse <asepharyanalm001@gmail.com> 1779064141 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779064141 +0700

style: format and lint codebase using Biome v2
`T͐xtree a4dcfd12d72020fb50f9c15fc26853cb845dc75e
parent 9c5a855f7d21d97ef83d8acd063a02aafbb5fea8
author MythEclipse <asepharyanalm001@gmail.com> 1779064026 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779064026 +0700

feat: migrate entire codebase to TypeScript
BVxtree 452583dcb0bb3ec8615c0c32d4950456b31849ab
parent 252b2e4a1f7783c7a6789aa5d4c086ab1d500a44
author MythEclipse <asepharyanalm001@gmail.com> 1779063802 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779063802 +0700

chore: configure typescript, biome, and package scripts
mY x tree a4cdd6c1e107eebe38d3711d658c0767bc3dc301
parent 434905313c1986ec37652a09f31aabca0b2aaa8e
author MythEclipse <asepharyanalm001@gmail.com> 1779063390 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779063390 +0700

test: isolate module mocking and add sequential test script
`[x^tree 6d3325bbc2799882aeca9668583b01e0efd79e48
parent 99b820709d01052527a3ce4734ff212c200bb67d
author MythEclipse <asepharyanalm001@gmail.com> 1779063305 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779063305 +0700

refactor: replace TypeScript with ESM JavaScript

Remove index.ts and tsconfig.json, update package.json, and install dependencies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ӧ4x_tree d83fa65f7a2c7ebc08af96039beb97a10d75a504
parent 4996d7548a1b932d1fb8d01d46db3bf1f8973800
author MythEclipse <asepharyanalm001@gmail.com> 1779063275 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779063275 +0700

feat: setup HTTP server and bootstrap application

Setup HTTP server with Bun.serve and bootstrap Telegram bot and other services.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
˛xdtree 69ed9ba9274b53a95e804df5690a8d8245e6f6d1
parent be93a06918531e07905dc3d61a685c7ca99ebc99
author MythEclipse <asepharyanalm001@gmail.com> 1779063197 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779063197 +0700

feat: add Telegram bot handler

Implement Telegraf bot instance with file upload/forward handlers and mock test verification.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
o+x}tree b804e9db8018d05f26a686c154070ea10e0234d1
parent d915814277e4c30f21a14c8f52a9e8fbdd84e0d7
author MythEclipse <asepharyanalm001@gmail.com> 1779063055 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779063055 +0700

feat: add health check route

Implement handleHealth endpoint for Bun.serve to verify DB connection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
{xNtree 6cb43165ebf341a863b3ebea00aaee39d8de54e3
parent d7b8e9dd086517865ef64ae09610549bf1e9ca68
author MythEclipse <asepharyanalm001@gmail.com> 1779062969 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062969 +0700

feat: add file routes (download redirect & info)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
jxOtree 8afb941670dbeb0ec568031a6d04cdfc453d72b5
parent 1e29433d5d6ccf3f3982dce5862b10fa9833bb88
author MythEclipse <asepharyanalm001@gmail.com> 1779062893 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062893 +0700

feat: add upload API handler (multipart & base64)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
icx>tree 5b604121a90eed6694ec3b040754e9a197aca9d4
parent b621a73ca9a0548043082f989f4486b0f84d20f7
author MythEclipse <asepharyanalm001@gmail.com> 1779062781 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062781 +0700

feat: add Telegram API utilities

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
bxAtree e972b38fb43515d4af00a7adb7bc18ed6d2df791
parent db71f2928831867a7ebfed3063cb04791a92318c
author MythEclipse <asepharyanalm001@gmail.com> 1779062534 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062534 +0700

feat: add file validation utilities

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ReӞx>tree 96ea07962b9f8e5ae7c158b61e48ca87eae76c7b
parent 281329a1afad5d87ef17215963272e4c0a814787
author MythEclipse <asepharyanalm001@gmail.com> 1779062492 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062492 +0700

feat: add in-memory rate limiter

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
[cxBtree 97422055f2943fe49f550c3429e829885216f7ed
parent f9c0bc076893eb040fdc59e95a9cbd7c08df5da8
author MythEclipse <asepharyanalm001@gmail.com> 1779062446 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062446 +0700

feat: add Drizzle ORM database layer

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
eexHtree b96e8a268e19ef296429991b1ab4c70f424ed5c2
parent 77032538e8bffb263f793c974ead979ca771ccdf
author MythEclipse <asepharyanalm001@gmail.com> 1779062335 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062335 +0700

feat: add environment variables validation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
"hx<tree 17dbff3e388f7c293abd61d88d72696d099169f5
parent 25c82bcf0b70907221eb56fb34a6631603c02388
author MythEclipse <asepharyanalm001@gmail.com> 1779062013 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062013 +0700

feat: add Winston logger setup

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
0bxGtree ab5243c311cad0d5e631c4d9e27357f09d491d01
parent f423e1f25df3d165e3635c6992010fc84e6585b6
author MythEclipse <asepharyanalm001@gmail.com> 1779061866 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779061866 +0700

chore: add dependencies and configuration

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
g@xtree 67be35fa7bfe978a8a536d17f5da9e981a86ce70
parent 3fb3dc3d40c8351efd488bf094049b42df93f831
author MythEclipse <asepharyanalm001@gmail.com> 1779061733 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779061733 +0700

docs: add telegram uploader backend implementation plan
Ztxtree a2975f17297ac440fabe924e3efc6c05cb73c6bb
parent cf55f6dee8872e65c63e2772754dc2ae03c0e3f9
author MythEclipse <asepharyanalm001@gmail.com> 1779061362 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779061362 +0700

docs: add telegram uploader backend design spec
D!WPxtree 8d70b092d2c44596b3bb7f248945874a29f1c862
author MythEclipse <asepharyanalm001@gmail.com> 1779060842 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779060842 +0700

Initialize TeleUploader project with Bun, including configuration files and basic setup
)W%x340031QKON-L/Je`<;5S/S˫BV$2<PL(4COL71 ̒$Qc8J.dX8s3	j_x6MIL:Tc^n
C͹3~]wygNA"2Ne6zhHQр+2]\]|F]6Jv]wדּ_*JM*cx5I
&dsC兩*Z`1SnՍnr~nA~q^en}Z?<[em>a4$->)U䏎:P 5 19;1k:x,iXÅPuzŅ9+]ﾴVT<mˊ$j9ԗnD9-I-.a("R&rݳX jEIqr~^Zf:-+/p6v5 sx. node_modules
.git
.github
docs
logs
dist
.env
4xMN0F.nlSII3U6T%Ʒ]h>|vRL>Ff0g?PdEC%Ulon1њ⤋х,I.B:4ʩ!	Oo衛yYLn5=Jc0q$vjboG8;2N͢*(BeMRC%}=XyYx$ 40000 workflows #Y4eHu`^(px& 100644 deploy.yml ;Tqh_y0?KZ  xW[s6~W;&	0K'h,2@+
+ɤ4#|dw҇b[;t]Ґ=,(F)0k-aKz5Ce*AuH"mhcȀHO I[~u%95QHT
G[4rwAYw/<c$UYKmА
Q1ɻXrG%A72A.o`X.F]hndi䆊f1_LgjܿM7C;?a|MP6&ZhknkãWJQVM_.<yMg$*@z^x8, kfƑWR `FWL
i
yńpk|*aj+ɜ	UlƇgXfH)p!I i+j{}0ax0ؖhbYdǺxX>^ti}FE4hJgomYi`e=ep[)ϻ%臻9|\0ԓ{C@=;m_N&'}љhnv_F}Bkn`	'M20??`|5)%0_Kh~MR#88Q]tcM&-h
	-:u[^<#1#oBgzRc?]VRc$,(^WdbKkS!k;K-PXvMZ̷$?lنS+GC/T2	\LhcQV M%beDA`G+M}d
qڳ6vuWa uX~DA?nr!%FIjogEק`HXpLCi]dpBJezDS}-&Pu{߈줶QJV_E;'0Mr]YD
,_gT72A.s%ep0R}q*M8_"H7y?9oyqC6 ^7֦e-i?z 9A_&xmP=k0+t~-\tXJpl%UIV2NzOӗ.ጒP"aqaGR,0@4aӴ0Š$*goWK*f&r\9xX}8k6vE&GYbnFjmC	WdaϹT~GwN
ck[62,ĉdԦ!3=!Lzy0kwrrsHKwOoxmVmo6_qS)l	žu]]q%:f"
I6=GJ$@"~]Ob6%1ME2V9-}UL3J6/dȗT`	JSbl6ر{aGCM#v,s'k73ĕiv	G
9- TWz3T9)&ӲiĘګ~\9ЂJoŒ*˴_{	ZicUZ#*EzQ'GT~pGFG1H4u5]lV*LzYM	IES3Zc!y(P鄶|Ga3cȥR8ʓ{n{e>mZ>,8ٙF{zVho|gϷ&97Q+F(|~L
38,y_ЍAp3hS^'mv&\"~CSГcBi-2KϴժSτCwDQ(+]&tZȪ3~ayxU_[6v7F(uxld+T6+ib튵I}ކŞ"_82Ϲ(ù1<&<~m=}A?WbM*#&W}('O$9:ii"5<{1JUa.?owۛo{|^s?jp6
ekӈ`YP%r(
UؕzNV7Պ,B<=}|8lj!D aJh4-K.5`p`ι/J?(YǨ7Y+Yi6vp߯ȳpDʏKb~9"Ƌ'@X˰TyS.mۓp-c9ܧv'0!ak?HV;~s]3fJ?|2XMxf*㬐< [%spwy?EK$WY8p 6<cgT7X?0pv	0ma	=Yt/މjt?`vlW @s2Wl+b@3_71&fz]T=z'xuOK1nZ<h[HݲETC6llYz
潗3fݖԁI\ur>g"4wP3(pgb5B#XKP3d$Tt5{XeApѢ>&߭,UTU\}#Ǩ|	<m"JY57:eʄ<Oeq8%5o.҈¼3K+x4i up,}c-GM$IcT{ kn>m>EZYZVObt;7,3jYK-gc۲WI̸o[xeFUͅL 0߱x=rFv<ec493Z̊[$  β*ySRJm/O9Q٭TelӧO[7?g^g^E;qr^"fNG!{8a{u0g]GɥǏ7gÓꌾYt{I]#XfM+/ޕD1¬ěF>LDƛ.3?ub#eY|I~ʰeNzٙv6e^%0{Itìqg-nt%0dhn4?~9])x{&/8w[J]ٜK'EleM5ɾ;=}[m.JY~s,J+m@3qRentbK)ѶL~C_-C;+L&Џ n :ߞYC+K2ȜЋipI[D-KcD7.K"N`H"̱0B'|#kM?Nh|9{leɒhFL4ލS2Fa;I&r]x[9v^| ĳBq$.JC/𪪹+']/4wE Tm)pL6hG$	؇dvA$̀¢X8b*]Ox(8hX{$$Zf^ʉ"o{IOiLҮ^D
_5޽],\?)id:k tH`incOΒ:$j@$&3Q&Ke$Ҡ	DkDT	%h\طD@X֓h ~ߵ1,Z98SB2ҟQPN{G{Et0-Zݲw=Zj>f}6"/F-N}&-Qxϖ	
2JAf^MzfI{BIA¡*dC>зBgmΫT[mEXm/up5]	<^I#4{0P\|pnC";/b"`thp~vr:LoVbzDǞ	CIUOg7<uTSYmz0^y_ٽp#t 
=JڿH$l+ 5VBMQcJEƫo9)z!ۓ7Ãv_^CN<".;ɭ:6 9_10Y .Oҏ46@̡铝$\
dۓ'^Q!>÷o?wIOGo<?>!Qust4]'ZQ90@Άhw)(<`hN2쇽`bgC=eg؂k0¼V,4WCQ_*YuN\[ږkq|Ԑ5s#轣wj.NNǰdoнF! m[b|zg1KZVir)
(4hZ(upK\?_52A8Ktckf `1DWWHdldmA;cF7wm@eh1	+q-г~!JӼcxjg#΀"ҥ7ْB{0mǍz >sF`>gto
r,&\/㢧p5.[18~ƤEdO[)5ltWcqF<ã'A1!iя.[)赏YG%9
5dEԀHC`)hcwwy01_x!0ƹL.ʈ?cb.Ð>**W0K>V1@osU
BҌ&2t%F+̀KqrXP4@DLfqݜ_~kEhSjulk:Xnؐ[U8AB
q
"wDL")~56lW\q-t2\X}ԽS:S?5Qdv@ɦo8g"PX;z/I$>*^k[UZJS5G ,K5{ߕ"@I]c'C-
<JAoN$BPL QBZQq~C'@.5bßͶpʚjxkPp٥6hCcNex(3', p؃(D0Y<K}˚٢C
}ްŃM4( >󤀔 RʤZ<]`ԫFû AP[)e\qGɭGP:1ʧbIv
jC FUڤJ<e6<Wdx}4rcUMֺ@*	lՋ*/{yr=bа(E3C5eK+50]RT'`,c(6YpG-ytKC~dƖ!,=.n6	}Ā{r47.ZA*a7[X摠SeЛ>h$4qDפX6S}5[qnIrq)DSL6n]su4tϒ^"g_*
's	"oTV4FYRÍͮʤҀx(*hH#	cH"VsP%4QHl۶!Ė/t9Ia3;m	dʀt.M]n{#^@;tn+S:Re((;6WFlȌ(+d,spzgk-"֌s .)QMhL04vYg
:ቷ]aI(jSGVw-P{jُ:,ҷ6"0gP5'hؠ5pA/x,Ns[2{&
*sjr}1@2\.&'WtSU&!aq0<J 
N+Lt$=tc'M\0rj%RI{btH5Rqt]', te-2ĒD*e#vBl>%#i"}]#JܹTjkX^t(P˟ V9s@VʷUZL{GCj]R9vM
f,ZP۽,N	t(8aq
P8y}pDE12r'u۵v0Jhꭧi 4Uv󙴛'(.|?̉\טPF7Uqb/4i; i#x	EUzmks0NЮv0<a>>3`ϝ;JcyK̈kƃp_ah쫆"W%.Лkq[l)joMM+Jxu?Xg/G"[krQv!I 9QGw3<6'r$q> 2	م"Dy}E0p+!w@(Q.bHO0,M"Ա8{b%72V~Y?;^s!9X,-`!rݻeXPg٘*\*M2gЇݲːt[}ES ||=<`$ n4]Mfimy<"۽#]/ڠ)g׫jJyce9!ߺ^eE)NSKqA)K;cJ0Vl}xig!8jZYOfKE501 k<496уJh z	ii2pIrTu4͖1b*Mwe(\4x
k*@)I K؟.eφPMUPPLIl1h#Mgm,=TqIe/Q~߼ E>hLx	^ O32AenX#{Vg/"Ih
b=M~|EU)Xv$C,3ײLkYV,3+YVB,3NJlJ5hyY',fVUvtx]QJUegnj7КX薑<XVG"ҡ0O}j+[<]Z,Xz:89;:]}}[,Jf]~Ҫ;.hsJЦކSfϗe[e;+DdO}-`;F~/>ތAq0D^{ c#tӤw؈\vͩkrIxs=:-P)>)6dBHWdl,]~Rl:g8oځ~M0<?g_6BNUǷӨ+:pS>v%jE @?X\+eJ9fi`%?k!D eG:*qĜTN* 3HQc4cKPЯd؋B'NϸF?O5(
˽~;f[=d8&{g=ޖq+|㖅`ǊyX㐢GV1kz)CR9}@#@&VI&~VUvc'״U]0|WO_߯ȮOprɭ^ίs}?[oPYވZHc`NO-NJbcdML!?s[-wU̘*#oýv1jR'	&P̼<\PfޑMD5 .J6E5F(l*6bi\OyuP7[ZC		>rMD7pM_%E\*>rAo;2z-vh2F{qk
kTNVĳ PWƑʙL~϶,JTɅUAie⬏6AP%~5P+qOY̭M5%'gfLn;IܾZ^\xI16 K5TkPI^Y96䔕qI{reLA
}wORac+W3etи6>wi}	!lln5ؽrVUd͖GܤX'#C1p򾰜sf|mvO-aªBhR´J<ku+u)ƃI2Ὦ?jkVYĉJ5>e+$a>y_g,ގ8c]>,/GW݋rWcz!\%xg=LI9gh=÷ͬmȵr	QK;~-תO\P:iG$h3ec6W;)0<lFPiH8 ZaH	Y%eh!Ʒ%Lo"
FW"[XϢ1⍖]O ndz#K6Yci>= 1?1<f>F!&gVިy6zIb>c|=)y*	u	uA,wI~
JNM25%_eH9Դ6=<؇%[uhm#+5# az%Qb+moÌZ6uom,P'=܁;W"ZF<UYۧ%Fw*hzeiqp0V
+~M=жk7dV~~rWC̨%R?<<jMb64ƥ\#~&es~WkҦWPT{X]gw^j\([t(Fp^o;rpWFwrl+ctd{ғh/+eZgaݕ*M%RQYpʶkj@*Ff}HrԹ92BI2.`Y/=7F&ۊzWqsu5lͯoQk7: G|
Fz qO6_sPj'^ʛ/<Q~/.:<Q{`o3m}
7aMEfcWc1/+w?T	2M,X6of}zރ!_W]("rK""{C,bAnEđMBQ_><^8onH 88崍Vy^C/*x/U9[}ל|"8]S<"^¹g,ӵ;\^w|B^?`ٴ!^-|bc%`9ЖU ube[,l$V-/$ei+Ud+mޛznޱɚJZ
wl//l?G6R͛@"򀤶!Y<SEB%s2⒚xI}>3?>%>8ߊsJ_x8j
ͣ'[(FjzSrzssk$(#;HLOI=l__gpf\ZjC2qX|+o75ejzDm,x
= =J[| q\L?,]~˧ssBM=芟Gt{R}%68<XI%mWQ˜x]8o(7߱A~CIi F(wU7(6M-5$SVvDnQW~_dL"s(L,AyQqm~1@2/5ɿ_|JCsUc|'Ő>0wFi#dL=Tf#eFW)xER:йpa:EPt3f>YlUǈhUx-`W p|wtgVߊАQ7#OY!Wsx|W瀇.U*<C΂nzZZZO
7~WZ'}vFFn|c6[G_VD*8Y+R!d*NE 2HL6rUR&wc3ԑ(Fu5-x{ށx*s4	w{^{kU_kUuz%{qWĻp>9LЃX*ċi?x	9>Odg_n[B[zK.fSup?>MLMgoE[U! G~鈌>*-ĩe߃
Gwy7%ߪɶr@b5UH'{zc_BwZ=:DB+/X/o_ְ4Oz;
y`@|<WY
B/%2IWϺd#_t b/iꉃX?~qOW+1 ~897-APa2~9ɄuSBc[T~HQss	aR~%{/O)5l-wk>(d}ܶ'۫NNM9c_ t5бɍ㵟 ]Ozܼ{nx +ɔ=bH_!k#$B:ԈJІwj;,<goM#1{E$ܵ$rkTQ%m9_U'ޕ]b5U ennE}˽Lؓ`X}	)[6{2gDN:GPLTS=QAk(coIͮ;zbĳy	QȩDCE;o񒔾eoZ6n4:dS/tŋTnWwPt3ov2QYɵO4ijsl\=čƩzV=2GE]ers}׿oV{84£H>)O	VyRӘ}ژ:o)?"I1%Ger0FA^n6oҰ1ت$^gZI;.LS^ꋧŵq[=l#ioCK[u%q-xƉ7ԇ&'獵X S;*cq vwz:_uVQGJLGH=Wz
O1ywt4r9Ǐa=ALY]4g=e|)՘xٵxݡzbHL z<HVP

.ȿƌO"Uzl$tG>	s)a9Ǐ(lZx #n'xu{
  "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
  "assist": {
    "actions": {
      "source": {
        "organizeImports": "on"
      }
    }
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "style": {
        "noNonNullAssertion": "off"
      },
      "suspicious": {
        "noExplicitAny": "off"
      }
    }
  },
  "formatter": {
    "enabled": true,
    "formatWithErrors": false,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "semicolons": "always"
    }
  }
}
ƭhx̽ג:.x?OǺ<iEQO4DoD#Q8{NE͉}TK*YkM	$	 H ׿J?9i&.~HGCQ'MasIߗK9wWaW_~2̃07?*\0̣8??<Ꮲ	'"w"3?/o1	ŖEF?H=>b?C7c=|{Uyg>EcAχz3ک0/gGsO'
RC-usFo%7I^8hſWQq[oG֗={dDiwEuM%w~t(%wOT?yʿ^,G.`yP<mdFuzNB_-{d	71 yQ:D*EI='
m?FHǹu=<eT[HմJ0jvs	C*"%2RwC7M$;Y4Ōc$FR!u:4~h>9 (4nZ|zgwFՅd9V%
~vfmC0׭yT8À&&:jd[bPIEHYҦidH!|hт  ciz|̠8JrNtvΕzT0@+w`m<+P؃N._Iֽf}˭Afa=K29Vęw8&nƉj)jq]yiZǲqq	BJF|n1J(aBo1Q:؄	:Q+:I1y\؍[!${HQb5[
Ym@8}|]߿? #;% = Fw1m|3vF)*Tj*tk9ŭtj"Z꺓cԋgW!sW^V23U"Pŀ5Z}22*HnVb 骫4ʌ`i'Vry)Tv<fn	pu񋴨/>{Bp?IO፯RjFsBjɚۃЙnRUdr>p7b&}&ȉY	-pn_f6ROOޚȗGψlGbsKR~>K/v)	]*41ٰ]3,s:ZZ+~>Zrذxh4^@3YP ׂyF@..}/bDHiFQ>]@<[kIfE5[j;	wZߍGrQjUDyr)]_8KᏮgYDp{Ob=]?Gl[h,C2oDh:+:0=[y"{{LUWDB'A8\얾gC-\ UĬ PEȽ;x$ZMyd?'kɻ*s~XjG</gUw:2e3VN*9X [mNQT-֙3GS*mվ׋C~YYM}tGY/]~^[0r]~O%!i>j(;Gŧb5fC`:.Jk{`K  &3T1`Ts. A}>=7 d|zPFdKw}EܥWD>ͻ:hPW"FA4urK嗉YBu elWF_y'Y̍\p$v9ՙ B*ȦH7#ш_Q"9#0߁r;.Ξ Ítj"Xss_YpDXP7NkVL vUqHy?otNt/[񵑄۳tW>ʁoO|[ϻý,0\ؗ{eԭ|AÇOɛ9wqJۮG8{hSyyqi]zڬbiWA~D\ZCtVMt)#Xq͗CR>LP"b,>P6>Q"(:t݄*kqLw*;z'9a"k'Qc7h)O5}N*o2V.&~vvmS褶Z_[/Ť-vZRԓP2S-S_֏uu):;+ưStDp4)AqD/Cf)tl;rI\'c޵w;ƍ,o..-J0T5{Zpc.=
  LϸpHUs,/?[hJwL{]e\MXܓeYB<Z7,Xo|ZD@Htc'Gl@M񲔡=ngAd[iQ}<g}K^RpWu=̲c糄r3{LAc?\=eL;0Tl0@܁,.{`^1xkindFj9+$押osVbtskd  fW~Jpx`>k(wl7~\e=B3jmt;2df!][A)8[L8h8gLRoX7~s?S/E;EUh"욵Y)?ËÖ)6l%eJUۉ6zu34
!y.R1U?2>hcsWD )آaoª|i/,~#J8=ϩYj4k-?󟵕w'7A]ȦTv|02>ps[҅C2Vej
23UX*[ImG@賡d߬0KaEl
7NnWhӕA<>	X."Plq rJh}}a<NҿVR(t&0w]zGjDL`e:csC]&ԊΠhT53*pX\*#$DY&.6Tɖ_-[+dHC/I,%kTr^(1.9*kK@-5yiN&o2V.3VphPC]3]åtCeǽ4UF״)m?v0)[3?+#\eRgaT7l\:P|l	F ǥ~SSPKiiQGy'+; y/0<|"t`:Zs廥W̴Dj$Qt+Q(lRn)5$H;E4,ڀ!fQ9坫NG0r&]c`47.zd8^dyze6WVZ=WzbGu jfxgsIgU=(*ar&	Ŏ=H~C\^(]$ǊV\X{@GctYx]uSڐ f~t&O&KeHKT7QQdنFdrwNNu;u^=q6s;w	l=ƪlxY\ V	ƌB^wc͆&kL̇H#e wEvDN(4s?a7oӮ7E_h~?QaBq$Vx(W("G=?@ݷ?x*=xmϑBO7#ZӹDLè"ÍFdz)xf׫iB&sN4Zf;gp!wTˍm;҉;=uDgP qFwS:<[sS%uq#ٱk` f6d<r\5݌w#(h304nRGױΰȃܝÑ,['cRȼH`2e(^-pn?kTtt6/Rq:K8XDTj0"'ACVK4U#M_@#(_
BJV{$z]#_/|]xS0~\RQn՜0BJ8C>76"XTdZ¬6nE5n1)1=4itO.	*ϧ4Ȗ2*M֥ HHm*.V)LpdR@M歶o<Wa߾z	»<,"O_MeH9dc4k, #BVSJO:3p(kH_h83]4n՛2V&߮Cؘ	"«䃶GDEtcʰr{<GasvbK<дl|.Y,GU^Q?"o"MPoah78{U6|"-ѺsuWf{gU_@
dh~ -g7j9&63}\;\;f'O?--'-m18M$GvѐGmZon.(y֕!#PKfkȌq!F;bCo.BG>Է?qb'?|&8iMms-ؑ7	lg
v6tc
";Z/<Dn33c4(hTG(^/\H,m  Y
o OÝK&pGjgMɤJͺdk䀱h3L*c@E+5p\W߾ߪ[s8*.L{Qٹ</Փҕm=u<Yzb3.[)(!ep]m;28x{Ȯ?ս5`oi}0<׍S	x#@5x߉ǥASlVDWdZltvv'1467\?o}:_ze(ȸvC"ٟ~,Q>EZOreÄ|[4aGl2)Z-dJuXqQӯSC_wH铧QOc5pv#+fk`G"~)NɸCE`x]1}-Dp)15M)^ކ1UGa8#lYpbP~8'n|~\),𭷔xA;IS BQ<&W׮+&~y|'2&q^cE}&ZiE
*i ((,,J'^8mGP0O}קz<3 d1&JQYǚ)V<¢
W	%򔂎px㴙0׋,&?8\C]aöOvGw-lXi ƃ̤>dPܑhY#MTiC=f*]+6iKϯtz^||ҽ{B{80z~G9ͣeO~X٠rI韛
\m:`yLa+;[Pv9bq֛`%YBxJo(y7ܫ֛_/^wh~4A$`A#p[d~T_~Zt1u|2u5zNCPMem5{|LcpIoۄ`֗.]Q>OÇ^^/%2<\~Ū'-g		?ͮS7w|ͧ/Z?8k2?>mzr!y~SJW6ۜ97/lˏE
o=ϋb2ez+OXWu
c/6SRI4}*0^_R	 k+O%\~9{^hi?Oh?OU#~(g
u&~ߕӺ
wOh{ҦEzV0tIIjxm' pMFӁlΥP(90e\TGF-3se"5`22Ĭ~y|0 ZYI7d<,Oֆ*
%10'UZ%!m2S'@xńw2$M~Eu9$;*? O>)mP_	ş@OD }B0ocf>!z	Fʻ	Rn?%껄oWl3_}!6<تe_^OӨán/iq7)їP_am
p͉Dnntۍ)hj-l*Pvb$\3y~O}pwDl/7fu%	gLC\d;3(3J'!^F=2.?&[W1O#WOg&UCx¹ 
1C3F«k0F9pfHagZ`̎g<V2%cuZD$cs5OX|y6Ԫj67Ï|>8j8uƅ~q_s<J6zyWyUUX0G8JˣKe{6prrUq/h)Cyh!0b+0x$P*"SI*(>Q&CWdz 3?d'Y\ch.!k*`,eIk5Ŷ'<)aEd8:\Mot14>{ֵ1e/ӽUCs}m^QA7\9[g&DO"뜊y5FoBłC*D<83^^9HO"b3<X![<xSH)V]{(J d˗*K#yCɮV9ӽd[6sڲopƂ#"1^2[BTf-GJRÒpTlǶ:cWJue-nѼ^G'o\1O^D`P>;L>+V́ιv^R!KlY^:A3G9M༨r_f:0Ok ֛vx^똼~yvC=ـcz.y||TZg:		(KʇN;%[Ol{GǄP?uq%쩝e/&;ϔTv4yqK[`
lmkx;/H-*fz-/E%z0?|BqX=R6KLs"M:m?Rkϭ\|yg)B ώP(y;#'o?ū&?v~tuz|M"xC?_j쯔WS9G+/S۽ x_A@O	@1̾7qeFt&$Ia|%0[RRK\ɯm<V\/ݫ?#-/[uq茵S(aE>lEkd+	VnsR0q@#SKJVj8ʳ,'3+!ݓapȸv}Í}itǁs`Ou`(xG蓸chF[^dle3L"]r/4˧~=a"5YayM.$eQ/+2ԺhxY|B~Rs7qW^CW~$w۸v:J$3RķSJ+p.[8lAGj9ZmѬJ	~1Gh'8w{W'Esuʯ'wϴv;cn3.[ӀU3<QOǋ"ܪǚ_n"/mpڶ
2
yV\|"v0j]4+җ\ʳmJ,ʎ<'QX_dSNu&j,9j[NJn.ҕ%trhoϏ=+jO_poQ0ԃ,S6K^stBne]lVmޏ0g_m˛8~<Hr}2F:`wINgmEֈHo5WXzU.<lt964F"U7_oQ>xOj_\:LJS`RYŴܯe(ZŮjR?<qiSZ*:F/^n]yԆ]e]~,{{'$ɴ^7)`j[yQ:]|sUEAi'o<U/ j m F_O'Ԕi"f{7~Us24nʦ<;vglOɖ959Ikk%e2Idŵ{vҗЖ@gY˯s
(hmxt;(=|ZZh!b4.<ZqvjJH;UE~$}8%ڕNmUւWv#&s+>(zzok.sw6PssGx
6[G"m#o>.?Uv8_cȉZ:;aHa[k9i}w\ibpbcЯ0oY/&:味Κn-\؉dVs Ll@JiȋQ[N&}R*\XыE>goZ_{%ṩ^-`=Hs;	4eNࡉԃ^'Ba>U)&.iyse3I4c޸@B"V7/Vb<2}8cǥ:{ͨ+1]PfcmWؘ4f^۾Ph1x>J]*9
Hc]|#Ś@3NZ	qexy5ԜuآcA:d
emj:Oby:.Qd}*]0V
 eˌɰ`Bep2dimgew3޾/Ŷ|4&FA;V"pZ/)Cʖk8u4_\U΢:ps9.]o 	2*=;s~=	h͎֪c8@m h':0q?o}n3egEp(p$B:G^sC޶қ!^Y: F)$a8!yIo`nցe5o2g6h)`K`:a\9%ꉱ_Х縧9r._SBE<$`[&oX+m	3BuaǫtwC^a^l}ޜ\NìV?o?8;wZQJ?7tE Atan}k梅8]a\Z;E Ĺ}Fåz߫wϾVI6V>'"D%ȁp݁ad;s_gJ9t@3׵޺~_Py3&[";'Yv̓|E%`OԀy*4q] GApLa&onF7I׳6U?-XD	ޟM@H# -uCRl 3d˲qyÊ*)t]	bx9˃<Fw<_uus@A'OKR|zd`O9ܓ=q>4BQ~fܪy!ʐpA*$نV42$(v%v%vgI4.NR:7{~|h-tݎUs$`Iw#<Tϼ1`̀V޹֍ItȷSʹEΥViLQޕ5?ryxlq<
xj]ۊ>b@{{p2 Ni5:+^Juv#WDul\ǋvSGVY@$wR0C9;H)pilRml	xma*YPG1>!n,o]| c>q~fFr	:"eR´lYٌQލ1ݸMd&NRqlHQ2ad{ݴu7/8A쮻y_ͿYxx|Xj$?ߴ?p4fںI57  Z3dcۋ?2gū1h
R:2nGug7ۿ~_%zvo^W@#DJ)	%j>^؎b&W--
522zx	Hͦ<1PjMc'qw,*(t-FF3U$4l	F'X#c|qUiCAasSսO~0G2Gg,FFNC'Sek`<Lve);7ٝ(*|msbczT="ʤZte>>]/C!z̻rL8ppZ4&	mz95O'I4-V2cW5Dȼi{ݩT_o_]5}ާa1ek!/{vufmtş%kC/K~O Z%G"m>: 
%_rb)={df`@O>ei(K]n"]֜H/ߧzޭUN( Բ]lW+`9fDґg- Wǥ2]U7Z0fق-ޛsu)ï*iA_&I[󕥟ɲ?*0,Q@ְ!G@{԰7E͵I?>sv|mk~L4~L>COJ1'܄~BUB!?
:#7\~D&#O*6=Ż$7	~DnQA0VcViyX:6̬ӎhLc<Qy29p*[6QT1dS>Fb<ЬCA˞?C;|\ƽxh>.6/Jx|:>M}w/>0ٝ H>.^G4r/66}x}O|:m{I++mځ6Y`X% (dIu}`m-_b-F%ؠ{/jd>U߹FTk[|/M(*i	䶊م<;ҫ=(t'%)vZzɉkky'WԅA{{fՂX]+;(EKqq	F)Ngpw>VQRd>G]~':suNU+s߱oB=` 5$|Ny1^4(o ]6׌}F$a4+V'*qk=+K>.o:_$S7` aiVn*I dؘC5t	n<N+h~DK̌sJc=cDOǑgA4[sԥcImF'YxOsD7ҿ;O&l˅})x:A{XYӃ%K;ɚΉo8j*GPM9`쬭0;{W	0Y̓	Vm56g35m5֡ҍkYq<j< 7̋om߳3umAyW:kxm.el,y,=:HNH6A˖i"ٓ>G-}¼%rXؒ;ak&&N!
%řWMUfG6'>sM
o o|w~pCZ"gauroM|^q dpư{#RɪH:
;v\:[Xߝ{"(VKF"v 3.B","UIyeF{H x$J	^7~"6X(k;>W|ޡ;#@!XjqokWqBՆ[OrKk:@ˋE{NB?#\&N'[WFMl0LpbR!XdE0-F<j濣ߺH	uo~Z>9C%sΔ.aZBdH81Tj
۲z}(_o
`o| @ (.{Nt˹%:;f~nIAڰ)*N8+鸑kwӋXU\Yy|٬_ZἯwa5I|*/}{zé1%o*NGBni+\cpVhfGqٮ(OK=	sNa3}ywQ~}^~tۣ?Eh%	f:D;(UX([ZC>Y8llwA~u|x}SBvueyY!X,(IotiQ>1
?k<.̯ϦL-7:ηp+ǄluI~,rn;p>\+*G*;3ݙ[">ѵS?ήw߾_nU"2eN67ag/Hy9G^b}lQZI<DvO϶wkG7b]pIAB۾sPf"4pR43I6,/ә@Dױoxq2÷.LOE:	[K?crccI"qh85 ٺʕ|[T<ܺG2i\}ѲYErN5g׳%A
aqi̦PrMk]i84D+QRA
tdl~׍[pE[iCN[M1D]t"YS| zip3u0rL@5hvϐ}uPݶ'`'D>Q<%NQ<L8q4HRcjLcDĦY%2H~;sL\M`?94j??bt ,vr`z/CU.%eh-GR
Z	ww 8R29;7gk[=o'lT^KI8p
fiC*@;9'1]~a9JV^Mx 3ǣe V
T6%fhHW<dXfF"$Y*'Ⱦ[nE֍Ar|ݠFXVT&h(l嘁hZ⡕k(^fT'!VV=wn꼃+ }v]}-Q%t0IfoRq媊9*?`b/KDt@J/.	i>zf߰b$/KN5NZ^ɑi^n41&yf>$ -ٝ(EewHɘ%TSІ.H:8#]X{U`uyBR!;=RIf>bo[fwwޑ4R,"~m{Ѥ{rν
mI"H30eM-jz4kD
mә!gYvN,4W}d'*IWrCŠ9بBq?4Na[32BD,Aۨޛm֫aОʰ5C%Ysۢ煨$Q4`kè4^pδRY>}WX߻j:iM4ƍتw&ۏ$UN|S=L	`լ_gZR+!z/|+YN{{7 wQ8s~'C0drxb`LU4T1N#wc:ƨ@*ތ|L4u^+V_\BV8##q|bt]l-WNc
nF@`nCZCF--!HM鳙qh{@i#% TD!@Iz!w/׀ne[t]+ZhUܔ] 6V9A%IɲZu]}\zwnmoѓجS,>~!:>Oաj'14E ;G\8kK2pĿ{[PP_YӒك$"+Îʜt L"xpptrs,a(^3s}O.'mk#)=ŴeHzV ȃ~KTq<-va*u/kΉйvQt9WXOTKx߹CܸbptMW\u^VLfɈPaFe,=ڞPez"ƅMrwkve9m0riɈ Vf`ݤf( 6%`0-(rdlanInwA1A9yfcpl|[	
jE@2O

0̭t.
MN* C/%;˝\s-ss=KMJBrKý$47/Ź\)8ϣ/72Ŭ4"5(5#Ǡ)25jj [Zx"M,83?JAXB8,39؊KA! D)(d&NȢ5y1bQjqIbQBbNybe1XEj^YfQ~^nj^dAN9d'PU
5600Ci<nFE8rRs6Nȩ2 g Nv\%9Ŷ%EJ\wsjOĥ2M&/<(NĢ]0МnYԊԢ+n Jx& 40000 superpowers ͌sTx@ 40000 plans jbZRn!Ǳ40000 specs z|Uח7.6x} 100644 2026-05-17-telegram-uploader-backend-plan.md yGkǇ[κxw=100644 2026-05-18-deploy-plan.md C<ycv+,axYms6_t1)6ŖƉ}^H	KI@J H
)M]	!f1PyZL eHEJ2	Iy1pFp8
/`8ppCׂ(rsvD1՜~ܑzgD,ǘװ
I$	ќrNq_`$I.@9nM|~<d 7at{℅N<\\)ppXd
N*+BNI"Xδ0)4g
C0$OPpS$InH&{`g/ݝhex4(K
Jie"M	S9W+"5jdy(Tk#pGCZə "M&LLI",LjdD	
n>M2Tg:nvii@i,֕ՍWhNt7QYAp+ڨCGX!EyO<+ |hYjc|S!$,cg<}xTh*Is<Ejxudbd@/Q̋,KLx18L;rre\'"
DyŠ	=&9G8a"].J@$WXE7`ϐcĠGPtwgCkx^-'}OdQZ^G|9˗+KaF˥)KK-pqMg$Ln#R&QtImit1U4M1&CS2-	Mٹ
5VB(܅Seh0Ƈ%^lnnFl<HΌ'jV»Kщhcyv'J4rY1xPȅh!g\z_^PmeI|g:ZNSiCCsgqໜ1]b2+d֓=x'`TD*-Љ8ikSКRie4F)}a۔tiHSDs5Ym.jwJ9kdޒ43 -´ax)9zt'xTPtZ`}Aڃ<et%4D@,QU0E^W\qƈE$B1묛|8_]zztu1<8gGHxppx0J\<::%`6(/N}0=\:N,78zؔK4n0p_tΥU3sԽʄ||EM|3bLMYEd"|DK `'XabE]*AUhԿi?t
[s;gq	UGcw2Gg5xI"nYъvtt4'X5UWݪ6X3@HtzQuK3*[<@t[^cH̉uR3ȦzL!bF
BL-m\J0UR8/J
P@wݮSH
5Bok^Ysֺb"L85RO=(d,-:cPGҭݽl
qR^+9_Ϝ%H.3^zÛݝR
rש;d
_]FH,zP%ݪVy+֒A3h<^J
kr7`(벿P<ﱡ+Єo̴DW.pwd|SokoTՃ߃S$ 6RWTh̉7|*ZJ(,ehtڌɔ-²Vv> 2:3&[pʌ/g㷃]Hocϡr!8/Oq#4CΚPeP>&/"p_Șf30^YiV8{)9f`݂;](_[w8qa>u_؁4Yݍ3q];cqZd\˺YA|X.若5;hЯlbrVuSYSi^+4i}g.JnEoY_PuO7XZl7a`O4ł{A*U#OS!z U'>OU0ԅs<nҔ]'&?^ۍ[fk置|ԃSӀ7Z7Cm8s^\7حnݑ[}oj(\d6@mUP HZjB	3Xb P SL,GSy g71iH,\
Rb2ߌ&J=<i-kWY90̈$xEOG"|Azl?{Rm\<ݏQz	M?e:{f8ʛx>||ݏl?~;9ӐY`kss^nm2S#^U26=#,cva/gO/}"_!l*َ3` ĄTB[Ax/%TӍ)"IYL1ћ2d<}N~/('bǒoml{t3T￶Rux ~100644 2026-05-17-telegram-uploader-backend-design.md ,}+5s{jzc100644 2026-05-18-deploy-design.md .&1J~^0ݗэ/lxrHb*/;o6.p{`Xh6ڪ{{{HwJik[/TDb$"YO=S98`ãׇ~`^ORfI՝ߞeqX6ص2JɇA5l4jO26ؘJ%	}'Ž]kfA*U|l˗iv[a)^.xcf<_H*4~O׬{ݯ7X"&q@W%ZKǱ,ZR =\p}L\T^8afM[Wknx&Q17H	@hPd44Tc=ێɔ*%(r$72d|tKֿ)ŗLPjFU@sirÖZmJs=!BNŹ:-j.́!OL|ko^4HĂP<@Zh-KE$,o>CLz|jf]%CAEe9NBKހ>+	 ۀ[Zu'CZ<%V2-N_G`|H%DNȯ)ْQ%Lpb5aZ7b	
̏rz0{miD,4=/<?A4PҪF$BK""v|Z(h%Ys1oB[o ϟ490dp=Lu-ȖueN*m(	)Ss9!g3M~W̓"~PjgzWԝii*Y?MF\=g8Oyh(¥?e`NERMZ"up҂<+RYCĽ3MK<n;JZ}^Qd7d7Vx7}y+@k'Wz[T *jjIjbEN6)_Dcs%~wil9TcUw+`8Ӡek ~Ug7GS6q
r *r^X R9_ӽ eEb{lFmSlŰ|򳸕B9(C=_Akн.lq~38ÿ97{/`RVwԊWȏOQUJ^y<fE_b]ϟ2m cEEqU*WՒ&:XՌ:4D\|NVԔ!5.H-d%GzvJI	}	%
]ayf+SQ"r8PM8h8 d@U/K&yYk=eQUaNg%j&D	TZVQF:jlqJY9LǝvZ8g򏺋ntsO7BkEщ@	l.(=O*MQr(sw_nk*:fقǭvS'~Ŵ~@9)ޥx7}|{gy'+t<J{cb363@NB]y,蠅]lφTP7,@DAna@ ƚj_E|^Kb4k .P=r"R@ASPUAC%9VeHU1'8A$eDx\ݪwŬ'W82\6>^6#fvŔ*kF+K)?"hluBGKxp&zxٹ9T\e:_q\Klm흍Ox,U"ћLG?Fq8~]>Bw֝3^NY¼^{Ydxtn̽?"(;OۭB2xzzz~<M%7]GpJ7l6_OС>ͮ&orte6aDL'@Чyˮhyuvzp`H܉teq\D"[j}B5Q֩VnXS=dU678LXK]Lfe`nTSPk۬Q@$Ks6u	"g[[6U]xO+*a}hC54nE<հn8ZJ
O2 0
Z,@E} WZ 36t:v	;g8l!S1sW u칣Z\lB 	2DܯQ,R8{Lі.^xdحwV	ocq_ȋKO\";0PvRZ1G
jp@Tv
3b!LTDQn	O$KS뒔}wt@!脿ꄿg\w[xz^u&YpHn_kd`Ǐȧ8"7x8iu-8w"Lt ) =T |y6}!̇ߢ3Bw]JtPv?.`(k_mC|K+L> |v`OW+_e |4`m˓}V*-!EI%*R1$%	\<(WSfR+)mp22eiTF,ǆ[x!r	iLQd慀H\F>Ed؅.dKHrǯ<'E6|KVI 	T$`ڢo&=z2ɚnMJUUJ#EdR&v
lper"~gKsr<I06XaG$й kcExHDI0#D[[|Vl2	f $kn9)UeW.s\ޕ&xVo~߿b=Ȯb;i~m6NgCDjw,1%H]!)Y@97ye}L6VzΗ+Yze+lk*tU-:fX#	
~E
fQzWװV~7vD;DSx5e%g ~֚X,'{Ff?'kS]e"H+d0d:<{2P%]>g-?g>	XJcK輪б籁-Wʓ,;0Ht9dEXd9ĐÕ^*WII%B@ȅyA[tZ
Surb;MB6	mkU_:56lf(f
	v`|Al4QZzcrP=A-3_jt kҲߒ!KԲc3nKJptFZڈ$:+/e]o(T2c|JYMR=%w=xj엮*w>$bNώ&D].Pvt N:T^* J!%P|`6M٧2rGߊN@8*%ʈ}Pgp4tHRՇŕZ5x`AJ(#P,o ZY>7ZpAQ)(6M-2!F6uo?eYJcZrUB^dj<5b_K·''rR,IK ܸpEYRq.f?k߻a2ίn>LɈե׃`O`a2(NWzݍnB'S;W'[7rȻ4g
rQㅷ{{-P؏|a;zuK|$YA}ܒэԒD˕8!&i{XaIC|~zTh=U/jh[ڇԤ.Ї{($]0C1- w['i~7ǿNQ,EA<fzq6a,:"	jfrIέ5Ԗ4-yJ1V-!L=)M]d&)cV̢hƞhxb";j(gttz'g$tن45:#?Y$s7	^M:-c,&ھ>^F>!Z/wc/D<ɋmJc*5%a:%Y(!1VNU5͋Sf}jzaqSDӍ.{E>*z8E*PiG@/bn1$"C>eG8Fa;LZHj)4u5[2r>!>woPU@5๚W1qDҦje?9+8S	|<2?IkX
V=Uͭhe6N`+1M8tcx;E4Ë=1P~X5))I9:-@ڬA=t-H%e_-GEx}S_O0藺'47Ĥ$4=2}%^~rw{0(@r֢dI\aʊd,\Pe+~r[CkM?#'*652gT|+2(l8kmCZR:H^S1xX,7fP2l/"b~;>WkhXT!8iozZ6y8ON{JHŮB>wD~<ןk0U6bjbY.u̷؄&a$mGj׿nMP7xetHAURܝnʧޞLYDPZlMLbTYv2ae;L
]銐	Ϧ^*yٽ)i8^&*lK.;tzĮqƳWɄ~)?LPfx" Bbun src/db/migrate.tsb1/xOO0|9BE{BFָO(KM<5y	FGÛ)t`2Dg$AOG"JRf TTb#bm g8PǣB*lCWwj;[*2}Wvl|URueWJ4ܬw;l\ltVjeu&N|(Y1L;l</΀^O;:]?A]E<ÿ;KSf|QtfpQ!6ߺ%x 9100644 bot.ts ^@;^ZJP_40000 db zrN)
	U-100644 env.ts #/|Z+<%$Y c100644 index.ts YDe.|~8`P40000 routes n0 wH7{=40000 utils nL';QR	1Ҳd9B{JxWn6S$mrknm@ӫiHFR=GOa'#eR¢xx<be%=pˡ%D5z~(\ӭ%-JeۉJTQv.[Ӓt:4_g/٪'|sb՚j&FB_wZH=sNDZXiPHRh8xw<8/}~Bp~g?rgКYXt .%=f,	z&u`~0ToSIb /'x?2k-"Kʱ\Zh1)>H3&XF񍳒h&xF8Ka_w>c9S@%نh
ٚpNq5*!7$kT)O2ad:WA
	_a@9kR;l˘7d;(u`$0ZI<_)1 1dkcjAx?DMQd4XLAx\O>Cmg~}")%1CpC0# 윍ua +s@ZRpt 3KDk#	Vx4jȏVX]@jlkbDvߠ4`!uC~^
g$uB*$7||8F|to\Wd%gwo*OG!cTHynV޹ܦr@M[~]S{FF2VΒl!(`|s
_gcB+wohhN>Ę14\>xkpJc@R]K޻mb64WxQB4o_Q2=OmeTՅ]y0dOHrs(NFQWVfUWuI ߬r!*9KCu&赝H0;mcdn9䮱Étk\$LSDiI;;
rx( MS,2ti5ɱ%M@|2N)jbϦQI6-$Q,rx9̟<okh{_>GnnB'v*DJCSx	P	s};qdԆ FQbsSN4deJ)dۯ)Xquc'bMĴ̺Cϼ\RF'|GV@cݦi P3fiϥԊƦ! :Hmu	t= R"lCJn?c-uuPԽ@{m/I 51=ج9YBNJ4Mm\uOB!X|H<GѡR8uv>x340031QKI+)f8]Vu("3a+;&73($*ܱШͫM"'B?3RsA]jQ+౉;n̚ >*5xMO;0&qc&Xi)i!v:!54JZL
BrJ$*ТdG.2}r|Eðȕ(SHAP)w1@c
`ݘcM9R1#Q۵ê4ĦO9=킿ޝtT%%B)nxmKn0D>wBA	i{`mJ#ѭwlPt%!!&!f62=+΍wb\%J(|=.Cٺ|تE{f݅<_<~ex֑[ObXݶv*?1GrOt#p2j EZ٠ouxkd#^rwI)tv]Rh,{@V(O9`H+ESI|\jйy(}XAxMo0v9lِ-$dŋ:CN^yIJz:=M~mzl2+Pa8@&XBf=ԲJp@$5ĵ+!)\\wXm]cMOJ=|-6@X0)y>X
lyPau,uj%uR^j`#g,mRX{~AOܨV)_~uƢ'y?pJl~؂媉z3?f38;=ceL+Rq,hD%vx1jj;%'EvmJHCJsSW+o<rBV.Q8o4üsq>жynlmJMF 
gYxTao0_J 	QI(hcuHN&BN)sZ}#wޝ)+%*z圕6	Z^P	<OWd4'k!d2f@HHNzbB2.㬏\׉e*&6Ke֔J2tkBpTS	!)%4caxINҧ܀F~q"25/8xGg+^®h\S00o"zj|j(n/,>FJ":*ZJ*XQm{mZl'hC3	=?a˪˴R)SVP wN}FgpθmM<[ZH-C8⫦q.}m7r;G6Mw5:ݵlf9S+jO4g[j~*NܷVR.`Loi︦cQO82Q#OuRJi}k΄NNFLU{I$మ!4g5ìfAp<x:nmq|Kf5soԔmwHf)hIR!	zm'6{U@{5_I7b{h[x}M0A${!ԮI~D	{+c#$];H7fy=_UTJ
%+|\i2_qrƏȤ(XA}Wa+
y{șV1z.i~jÅ%x46}Rus΅k.i>ϥ凨0gMGSw݈.ڸQ)r!Vr!/]z+b	Z>H!]
Z5"Y͑7ӀS/ҮXM-eo{0fpex,]XFL23\кc[{ ȃa><(>XTó;T4Scry\"2jV
6r";&Y?Ս2ZwbDjN<$;[DKM0tϱ6opLT:掽w}sdUs0Z/3@|N?pi2[	 -40*w+C/jmMf-	x340031QHI-+)fx(hZsh ͛BZ59% E}q-wS_F9C'T^u
;fNz~; '?1]펒	=N|U :öxV
import { eq } from 'drizzle-orm';
import { db, files as fileSchema } from '../db';
import logger from '../utils/logger';
import { checkRateLimit } from '../utils/rateLimit';

type RequestWithParams = Request & {
  params?: {
    public_id?: string;
  };
};

export const handleFileRedirect = async (req: RequestWithParams): Promise<Response> => {
  const public_id = req.params?.public_id;
  try {
    const ip = req.headers.get('x-forwarded-for') || '127.0.0.1';

    if (!public_id || !checkRateLimit(ip)) {
      return Response.json({ error: 'Rate limit exceeded' }, { status: 429 });
    }

    const result = await db
      .select()
      .from(fileSchema)
      .where(eq(fileSchema.publicId, public_id))
      .limit(1);

    if (!result.length) {
      logger.warn('File not found', { public_id });
      return Response.json({ error: 'File not found' }, { status: 404 });
    }

    const file = result[0];
    const { getBot } = await import('../utils/telegram');
    const bot = getBot();
    const fileInfo = await bot.telegram.getFile(file.telegramFileId);

    const redirectUrl = `https://api.telegram.org/file/bot${process.env.BOT_TOKEN}/${fileInfo.file_path}`;
    return new Response(null, {
      status: 302,
      headers: {
        Location: redirectUrl,
      },
    });
  } catch (error: any) {
    logger.error('File redirect error', { public_id, error: error.message });
    return Response.json({ error: 'Server error' }, { status: 500 });
  }
};

export const handleFileInfo = async (req: RequestWithParams): Promise<Response> => {
  const public_id = req.params?.public_id;
  try {
    if (!public_id) {
      return Response.json({ error: 'Missing file id' }, { status: 400 });
    }

    const result = await db
      .select()
      .from(fileSchema)
      .where(eq(fileSchema.publicId, public_id))
      .limit(1);

    if (!result.length) {
      logger.warn('File not found', { public_id });
      return Response.json({ error: 'File not found' }, { status: 404 });
    }

    const file = result[0];
    return Response.json(
      {
        public_id: file.publicId,
        file_name: file.fileName,
        mime_type: file.mimeType,
        size_bytes: file.sizeBytes,
        file_type: file.fileType,
        uploader_id: file.uploaderId,
        created_at:
          typeof file.createdAt === 'string'
            ? file.createdAt
            : (file.createdAt as Date).toISOString(),
      },
      { status: 200 },
    );
  } catch (error: any) {
    logger.error('File info error', { public_id, error: error.message });
    return Response.json({ error: 'Server error' }, { status: 500 });
  }
};
<dx =import { sql"B7Health = async (_req: RequestA+try {
    await db.execute(sql`SELECT 1`);
status: 'ok' }, { status: 2000Health check failed', {	5status: 'error'	;
O>Ix#-import { config } from '../env';

const errorSchema = (example: string) => ({
  type: 'object',
  properties: {
    error: { type: 'string', example },
  },
});

const jsonContent = (schema: object) => ({
  'application/json': { schema },
});

export const handleSwaggerJson = async (): Promise<Response> => {
  const spec = {
    openapi: '3.0.0',
    info: {
      title: 'TeleUploader API',
      version: '1.0.0',
      description: 'Telegram-backed file uploader API with redirect-based downloads.',
    },
    servers: [
      {
        url: '/',
        description: 'Current environment',
      },
    ],
    paths: {
      '/health': {
        get: {
          summary: 'Health Check',
          description: 'Checks database connectivity status.',
          responses: {
            '200': {
              description: 'Database is healthy',
              content: jsonContent({
                type: 'object',
                properties: {
                  status: { type: 'string', example: 'ok' },
                },
              }),
            },
            '500': {
              description: 'Database or server is unhealthy',
              content: jsonContent({
                type: 'object',
                properties: {
                  status: { type: 'string', example: 'error' },
                  error: { type: 'string', example: 'DB Connection Failed' },
                },
              }),
            },
          },
        },
      },
      '/api/upload': {
        post: {
          summary: 'Upload File',
          description: 'Uploads a file to Telegram storage via multipart/form-data or JSON base64.',
          requestBody: {
            required: true,
            content: {
              'multipart/form-data': {
                schema: {
                  type: 'object',
                  required: ['file'],
                  properties: {
                    file: {
                      type: 'string',
                      format: 'binary',
                      description: 'File binary payload.',
                    },
                    fileName: {
                      type: 'string',
                      description: 'Optional file name override.',
                    },
                  },
                },
              },
              'application/json': {
                schema: {
                  type: 'object',
                  required: ['file'],
                  properties: {
                    file: {
                      type: 'string',
                      description: 'Base64 encoded file content.',
                    },
                    fileName: {
                      type: 'string',
                      default: 'file',
                      description: 'Optional file name.',
                    },
                  },
                },
              },
            },
          },
          responses: {
            '200': {
              description: 'Successful upload metadata.',
              content: jsonContent({
                type: 'object',
                properties: {
                  public_id: { type: 'string', example: 'xYz123' },
                  telegram_file_id: { type: 'string', example: 'BQACAgQAAxkBA...' },
                  telegram_file_unique_id: { type: 'string', example: 'AgAD8w...' },
                  storage_chat_id: { type: 'integer', example: -1001234567890 },
                  storage_message_id: { type: 'integer', example: 42 },
                  file_name: { type: 'string', example: 'document.pdf' },
                  mime_type: { type: 'string', example: 'application/pdf' },
                  size_bytes: { type: 'integer', example: 1048576 },
                  file_type: { type: 'string', example: 'document' },
                  uploader_id: { type: 'integer', example: 0 },
                  created_at: {
                    type: 'string',
                    format: 'date-time',
                    example: '2026-05-18T10:00:00.000Z',
                  },
                  download_url: {
                    type: 'string',
                    example: `${config.baseUrl}/f/xYz123`,
                  },
                },
              }),
            },
            '400': {
              description: 'Bad request.',
              content: jsonContent(errorSchema('No file provided')),
            },
            '500': {
              description: 'Internal server error.',
              content: jsonContent(errorSchema('Upload failed')),
            },
          },
        },
      },
      '/f/{public_id}': {
        get: {
          summary: 'Redirect to Telegram File URL',
          description:
            'Gets a fresh Telegram download URL and redirects with 302. Rate-limited by IP.',
          parameters: [
            {
              name: 'public_id',
              in: 'path',
              required: true,
              description: 'Permanent public file ID.',
              schema: { type: 'string' },
            },
          ],
          responses: {
            '302': {
              description: 'Redirect to Telegram CDN URL.',
              headers: {
                Location: {
                  schema: {
                    type: 'string',
                    example: 'https://api.telegram.org/file/botTOKEN/documents/file_0.pdf',
                  },
                },
              },
            },
            '404': {
              description: 'File not found.',
              content: jsonContent(errorSchema('File not found')),
            },
            '429': {
              description: 'Rate limit exceeded.',
              content: jsonContent(errorSchema('Rate limit exceeded')),
            },
            '500': {
              description: 'Internal server error.',
              content: jsonContent(errorSchema('Server error')),
            },
          },
        },
      },
      '/file/{public_id}/info': {
        get: {
          summary: 'Get File Info',
          description: 'Gets saved file metadata by public ID.',
          parameters: [
            {
              name: 'public_id',
              in: 'path',
              required: true,
              description: 'Permanent public file ID.',
              schema: { type: 'string' },
            },
          ],
          responses: {
            '200': {
              description: 'File metadata.',
              content: jsonContent({
                type: 'object',
                properties: {
                  public_id: { type: 'string', example: 'xYz123' },
                  file_name: { type: 'string', example: 'document.pdf' },
                  mime_type: { type: 'string', example: 'application/pdf' },
                  size_bytes: { type: 'integer', example: 1048576 },
                  file_type: { type: 'string', example: 'document' },
                  uploader_id: { type: 'integer', example: 0 },
                  created_at: {
                    type: 'string',
                    format: 'date-time',
                    example: '2026-05-18T10:00:00.000Z',
                  },
                },
              }),
            },
            '400': {
              description: 'Missing public ID.',
              content: jsonContent(errorSchema('Missing file id')),
            },
            '404': {
              description: 'File not found.',
              content: jsonContent(errorSchema('File not found')),
            },
            '500': {
              description: 'Internal server error.',
              content: jsonContent(errorSchema('Server error')),
            },
          },
        },
      },
    },
  };

  return Response.json(spec, {
    status: 200,
    headers: {
      'access-control-allow-origin': '*',
    },
  });
};

export const handleSwaggerHtml = async (): Promise<Response> => {
  const html = `<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>TeleUploader API Documentation</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui.css">
    <style>
      html { box-sizing: border-box; overflow-y: scroll; }
      *, *::before, *::after { box-sizing: inherit; }
      body { margin: 0; background: #fafafa; }
    </style>
  </head>
  <body>
    <div id="swagger-ui"></div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui-bundle.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui-standalone-preset.js"></script>
    <script>
      window.onload = function() {
        window.ui = SwaggerUIBundle({
          url: '/swagger.json',
          dom_id: '#swagger-ui',
          deepLinking: true,
          presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
          plugins: [SwaggerUIBundle.plugins.DownloadUrl],
          layout: 'BaseLayout'
        });
      };
    </script>
  </body>
</html>`;

  return new Response(html, {
    status: 200,
    headers: {
      'content-type': 'text/html; charset=utf-8',
      'access-control-allow-origin': '*',
      'x-content-type-options': 'nosniff',
    },
  });
};
S{ZRxWo6_q	
H\=8KuŀKZ40.-67TI*Kv膾%ɻ}VBjX'\60LΎX(fc* ~Η"FX!|==%Xb1{-I/YE?j<XPmn͏JYNR,Tei\;"ZIBo״I*>VR%KA
8dx&~jң	CcLSU}	/a}~5xh%[RRP2t,MS 1}64˦*MԬ&RbӂhFYGt#2ȺL3+Z*zR%ˉf/oͻ(`SfBN ᪩Mh2&%ܠ;!a?،[Jݨ	<,[6R4CSf/JƝ#R/hwOg<;`cj`4{c0xQl?'ĮH>鶆GlTQZ20OmS13	5qcq)K~}qlxm)Lr%\Eү:-_4ːJ8^79tdf xgkz;5-g5O1N#r4~Ӫʡb8ݽIvd3tWz9nMq?ښ+4
b7"o*38C$edu1cױIZHD̝S{YeSэ6#q$Un+nWq_LhWIqۈsש{	e2_yӞbdGYN!jPorӋt&pvK3k{TePT.g?-6dӁxIWe&93GХm1Ŀ 9ejMnƗKP%Ɗ;AM5urTJv<qݒR`2G.ch͠\2FSh,l4GAkNrVv,"vRG~Gz,LFMZ`eZ\\V!6d[uFt~dU>vm]GˇM	`G-c
sӮ#]Gȴsx]bqh&.cR; Q1JJwi8H7cݫ>Row36W~wp&G?18;}	#{[@#a߈0ħ	x h100644 file.ts .V/ Ou`,C100644 logger.ts fc{_5>r(100644 rateLimit.ts ܴĒgh"100644 telegram.ts 3?b9o)p:mxT]o0}ϯaJȇD"jI4Eknk-Yk$D'{}|l.\w?|{72rd'I@vMfGH(yC|2!x4L> 4]K%}v ƣG#BP"7hI&s )!]ߦlR)F+P"m
(*E}YpkWRi
H(;-O[N뺊v;#^,	.FlT%v=[r}lMס%x
+hHxT;t%
=7J|BjSmΕ5ZM[Mv{{>2_$*ɲrW</RF}D"
9K;=HHf1TFeaq|w2#ke+@ୁ7q ]ZT- gkwL%NSo.vݮŊ̝W]@c1̉*k4ie9K^e
.Q@yN.`lWRQ0MY$7١
 57j]"q1xSMO0WSZiv)H*I7d 6$Tg#`dqԂ$V`Ǖʡ8O=0FSAH%u9Zg>>@hې1!PRQ-挀[A]^N9r>
gO6/8567ѭ
D@}gH>NWcw: Hc<hL_k6m0X:",/9Gml VIG1OX8nudx̆Z^Mv<EQC@}Ϸwvg)jQ@rF2I):-qqUE~ýA ^?Ql_3®I	h	xq ,,
  ],jConsole({
    p!?winston.format.colorize(), winston.format.simple()),
    }),
   )#Fxmimport logger from './logger';

interface RateLimitRecord {
  count: number;
  reset: number;
}

const rateLimitMap = new Map<string, RateLimitRecord>();

export const checkRateLimit = (key: string): boolean => {
  const now = Date.now();
  const windowMs = parseInt(process.env.RATE_LIMIT_WINDOW_MS!, 10) || 60000;
  const maxRequests = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS!, 10) || 30;

  if (!rateLimitMap.has(key)) {
    rateLimitMap.set(key, { count: 0, reset: now + windowMs });
  }

  const record = rateLimitMap.get(key)!;

  if (now > record.reset) {
    record.count = 0;
    record.reset = now + windowMs;
  }

  if (record.count >= maxRequests) {
    logger.warn('Rate limit exceeded', { key, count: record.count, reset: record.reset });
    return false;
  }

  record.count++;
  return true;
};

export const cleanupRateLimitCache = (): void => {
  const now = Date.now();
  const keysToDelete: string[] = [];

  for (const [key, record] of rateLimitMap.entries()) {
    if (now > record.reset) {
      keysToDelete.push(key);
    }
  }

  for (const key of keysToDelete) {
    rateLimitMap.delete(key);
  }
};
ܥlĶxVn6}WL ܾ4[؋xf$bc*I5uz7/:ɲXm̙3CRVtGA)E|S.xɶK(-8s?<{Jýа N`LѲOuuWoׯ7oަҺQlFTo_FHlqT$ZG"[V2َ(-GގJI-U
?=I7॰QKV/zP(ֳ!:"<2X*]Q+q2BbjC}{&dNy[SnSdxؓ{	>4	nN/aO!D6lI;A
9-"eB
 tCi3^G¬"R/H7"H%f|f.+ŗkjcz]iNpҘڱ/8ePqdahZyG&@sS{YaVr62?B%gG:OuV6R
b*V6S$1-9)Kdy<BmՕf`.s_Ñ>YY'<^J̈́.)o.d7'-&aoJ8腕c\S0=ck˾u(%sa: LqhܘubA|AybD) ;Kxm~YMW[S]	U|u\QԻT*pbDipbf茠ER{Vc'L,8ttS?q!&Ӿ;ӻp?;=C6qPg:1*oط7%C\Ԟ5[w2LvY|qn8 'BDx340031QH/).)J,+I-.+)f82U˩ܒ&^.nS\WAkSWB"ElRજ?Jptά:mԼ2pQnX!wiPei9puJS_^haG~ک*G}(gLtp@f&dUnءqg66R!>JCU%df"<ci%h~5^iSJCzzNyuWNV\y~BTiIjNjzQb.\ǖ ݚ9۴.+?*--OL♤z~5kř5 t`x  WoZ|=3IK*bxTۊ0}W̛p>'l&le>RdyHBtCȿWizم8u4gHs\ޚ7BSJj' E}y6RBj4T
sg	hrbmt$T
c#p,2 iC `"q]Ky'{0cZw䫰r$(@j჈	&^C<Wn[iGA/wNׅ ${Y;YZѴMs|M_j(VDѝA^DFdROq4ט>zk;O/"ᶽV."C,ggI9%0Y8z$\	i)BuYGK4&[P'r1DfԴQmRQQ40D~fsxLvewy6*#"ξ^̀G37`<z|{5iCoG4^r%Ql߫r\/{-ތ`#_;rLḊxXmo6_oUr(Ȱ8MlM=4	-Z-67ߑdɒ+̀-xI|xa@^("NCs(PLd$x<$Rx!mB9󂅊HU:S\aپHJH ]2D؊
2 <"R@'ӛ9o^^޼9''rW?q~MM7ӏ'f'>]*F_|=>|bglvLeǒ	k̰H4=AęT&Tqѯ*/銸Qc)2jK1#JҠx\uPy !AT!YE)R2E).\,TBopyyi2
;QQ/j1ύ`3l&
!y]2IV[q+D3`Je1ycnÆb>ic.`k^68a<F8D$if4v-la([iYp(<q'Ze_jl*VeH([|Q0]-;pj!3-8ZXdhBM5!*O	ϖ}?sPyAJuUv9`OLxƠ*S mTBМ+kfd<A`tp7SH%uh֠rjj[rAf}jBޒe$]*ٍh"B)NSFUiN5}9x(gî)ۣv;'Qgt++ELK
1F3Os=ϔ5jkQO"'tb0d{𞬖ق=>LX.}wzmы=:>>F6ûfA	km9"d4*­2-k~+kɨ)$|͔-O9S@ǖsSho{e6>T)Ǳ40p
qPRi϶2uJacI0Bb*HMr4Qryh{Eь:<A~ǋ
3-6{S-녾7}`m[}sf__XUγooA6ї"(%6aO?mXD#!12tQJ3kfQM5agD;p^)#}xnL^LZ:8$,[m/5@2)bF=0tƺ[	سExn0<o-Rއ6mM=&]N0ĻT YO9HuPӂUE[;[B$js9".4ͼ6V}уoƖz&UG3d	qM.J #lFƑm]Vh[JSS\RxT9خ:Ya9zFKãviGw^Ȱy:D敳9/Z]HZZL-?uPʳuo^!Ē.k]^t_}7>i]#dRVE۟}i
Qx̱cJ"B03 5YhxTn@`$j"ШU	E**;U̎&{װ&`lyޛy|敢x]K3@v2;4!ѴEc6&${F=TW*l6RZkɣJVb]:=<kVV|sQ@"1 ^zvBKES>,";;Ts٤X|cqtڰy!7,U$ЍauJ	n-NZR	1níۥK>߃݇hVp5/ϣ.r	#n+*p񄐃 j"kHeqz|v;4Zhf3HHuvtus\{,E߇ۆ""MU}V+mMHC[;|?ZԲ_鐔RCD1ޖ*i.M#G'M){^ƶIWVQi\(? x<"Nexpect, it } from 'bun:test';
import { config } from '../src/env';

describe('{ ', () => {
  it('&'
'botToken'=%'storageChatId'=%	'baseUrl'=%'databaseUrl'=%'port'=%	'nodeEnv'=%
'logLevel'=%'rateLimitWindowMs'=%&'rateLimitMaxRequests');
  });

  it('8'O'1H'"'number'd'5'port).toBe('number'P'test', 'development'x'info');
  });

  it('}<'\	#'<'\*{+xWMo6WMŮb#A`)E=%1HE{9\V!͛p6IV{!ijhx)[P3w5<C
V\ittdnhTWrfWq*̪pSD6\sl<] M`6*d[@KឧLBeX6
:Fao"3fwm79^rzMg9K)ӷUNCEW/y.2Y(@I2(ɷ.BjtK jaZqr!N
/0;lbXXY)8uɩH$Ln.GpBjf8LIUe-5N#o	T]:F'/.&+ڲ8:
A.u? [V]0PJ^Dw\z0|M˶א$x._}Y{M_v3oKIe,"QH^V7
8ƫ46U0sջf4x61oGOJ1n琂5*$9\]*-X?Ȍ9ϣ0byz!kBI4\
*^DkBV;'Ylr3!&laWKV=x@4AĮ f:0`yѭ:41n'Q~J<7tk8#~q@@໵306ݠlOф!vY}hJvO05M.	N4x(aOj395$;zy<LOp^5SzXK^ix	
ڃ v3?0ǹ	?%xXmo6_o[Rh]dX[.|0tHJRI}Go͚Ȱ~x%'?j5"CtLHM	jGI'!LS$IB"'?nȒLH|Aiޞ_,TI^$v$BĽ#k6J$l7a
<	J$g׷/4LR	JF~Zq8"ŚZ?jĂ)K@UzY&,:kBSN4)^d3(q 0ƥg$,Q-uS9ͅʷC19.=v9Z@tbT6~涷[ݫ>@yl{Y	2׀]h:eUJ5)5	h2y	1>/-
;क}.UHx50*]4,ncWhr	RD_\O/~9=G;//_ǃwNSDfDCB(F(6u@SNi躿pvWwncdo-ϬM(a;$X\GED%'7M-ARD*mY%T3/x%XLW	p{%,hd.AO}S,vje3R!}Y&Sef=UpM.BE!)KzcpU+EQG%
d<Rȹl]h!T		N0^keH]jeku/ >oi/[W:}%Sp9di<5]'uw36Z4&I<!KG@֩"'~ma,`Ew4#M])6U*s윦PȺruVr΄`Ig4Æ)~9rR;ۣxBlC_?Vr}D4-IȤixR8`9c9BbO+ #95}8M&rMMt7&\ϸZ41'WqX6ٝZ=t^M&e)\VwŊ67ugfkN%ThcjD<GCCpa0|5^^}{șߝx5G1)Sel Ncw]ݤݬh[[kfN(b;a'ωP=;vWU6PxS͎0)fGJj/*(=T{P86B{PEK3|W|uҼEͺ~34WtrW@^-\`ctT:9˪
~`5"HvBq_,HSzk1H,+kx%S@45_#{=*|RdHz52Zx͚)!hDm<O50u0ɇ2{R|r/%2Cv(ȗs¿jBVДRhim),|8-*Ύ'u{ ϸ)iU%5g՟fٝ+5+'LVRoWF_VטG$=@Ą&ȚQ-(2}3EcǱN[)W60GأVww%+YaJR+O={)$V{|BbA[,\'qex Ĉؽx// @ts-nocheck
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { checkRateLimit, cleanupRateLimitCache } from "../src/utils/rateLimit";
import logger from "../src/utils/logger";

// Spy on logger.warn
const warnSpy = spyOn(logger, "warn");

describe("Rate Limiter", () => {
  beforeEach(() => {
    warnSpy.mockClear();
    // Set custom env variables for predictable tests
    process.env.RATE_LIMIT_WINDOW_MS = "100"; // 100ms window
    process.env.RATE_LIMIT_MAX_REQUESTS = "3"; // max 3 requests
  });

  afterEach(() => {
    delete process.env.RATE_LIMIT_WINDOW_MS;
    delete process.env.RATE_LIMIT_MAX_REQUESTS;
  });

  it("should allow requests under the limit", () => {
    const key = "user-1";
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(warnSpy).not.toHaveBeenCalled();
  });

  it("should block requests exceeding the limit and log a warning", () => {
    const key = "user-2";
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);

    // 4th request exceeds limit of 3
    expect(checkRateLimit(key)).toBe(false);
    expect(warnSpy).toHaveBeenCalled();
    const callArgs = warnSpy.mock.calls[0];
    expect(callArgs[0]).toBe("Rate limit exceeded");
    expect(callArgs[1].key).toBe(key);
  });

  it("should reset request count after the window passes", async () => {
    const key = "user-3";
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(false); // blocked

    // Wait for window to expire (100ms)
    await new Promise((resolve) => setTimeout(resolve, 110));

    // Should be allowed again
    expect(checkRateLimit(key)).toBe(true);
  });

  it("should cleanup rate limit cache of expired keys", async () => {
    const key1 = "cleanup-1";
    const key2 = "cleanup-2";

    // Populate keys
    expect(checkRateLimit(key1)).toBe(true);
    expect(checkRateLimit(key2)).toBe(true);

    // Run cleanup immediately (none should be expired yet as 100ms hasn't passed)
    cleanupRateLimitCache();

    // Verify still tracked (counts shouldn't reset, e.g., if we consume remaining limits)
    expect(checkRateLimit(key1)).toBe(true); // request 2
    expect(checkRateLimit(key1)).toBe(true); // request 3
    expect(checkRateLimit(key1)).toBe(false); // request 4 (blocked)

    // Wait for window to expire
    await new Promise((resolve) => setTimeout(resolve, 110));

    // Run cleanup
    cleanupRateLimitCache();

    // Since they were deleted from the map, they should be initialized as new records
    // If they were cleaned up, we should be able to do 3 requests again
    expect(checkRateLimit(key1)).toBe(true); // 1
    expect(checkRateLimit(key1)).toBe(true); // 2
    expect(checkRateLimit(key1)).toBe(true); // 3
    expect(checkRateLimit(key1)).toBe(false); // 4 (blocked)
  });
});
فxSafterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import logger from '../src/utils/logger';
import { checkRs''../src/utils/rateLimit'7!'warn');

describe('Rate Limiter'4'100'<'3''%''user-1''PXP	$';''user-2''PXP'Rate limit exceeded'k6'2' = 'user-3''PXP,'|/'!)'cleanup-1';
    const key2 = 'cleanup-2'+_WxTo0_q<9Z7`؇M@'{@!79ivE>YOCs&X
Ĵ6o	w=%^ߩ[<Hg,j6ټА"pV
I
GǰGKG	0vh>\]@zwy=&"@)nr/urm'0XST=pȐ'}spBÀEi\Q$B9WnRf&:6K٢m)^@oKPNo7r("i34A'X+m<XNѢ{~g3ODdnIC1CXl)O.u,[;IgI?j'9=F@TuI)OjP(R^!u{w۩M>}7[n=ey)
F`1;b_O'_xy~	ٽ\q";ɬǓ/a{ʹxXn6}WCiAIh#чphibV+*"v~C?O"uߵ@p̙XwJ~0p}7(
,Lp(~P9o9j~7"\ATbu-J=vJSZaa`!pQ*EJ!YW2+	,NLqUXA*LN%FDe7O	@,2%)~}TE*QJ15K *20_	k- $fy"X|cSgHoV6(%[pV31O덞ǫTl	PfCiכg# J3c}?T{E#L3D	íIћ7FhB#|CV4[50m#<Ű(D1)gVKx߻p.)o({[VD-%ἤah
e^]v(1"+YaJ5KSÖ-#d7(s߾~Pl㝿^Џ1JY]]6<di)1)GLawAc(2ebfD=\]ڂS\jw&=;{Z6z(2e2tvp-4bYkcyliqFa3=)Y&~-+Yjj m Ä)Ǭ㺀	n0)5|Z1d[=#byhxG_}!eF=TKZF6h'M4V.gÆYCoS)4F?s>ֹO)bz|Cj!aHؒTZ"zҡS2RNۨ=N0ȣkUvĝnN@2qzJu[D1Cl!ןg{'VkOhtS7#_B"
ۄ&\5[k4F4C^;smDg;ym#ʠky7~kERmiM7˒ٮˑ+oܹ<[7 q)@aHKjr+tW<'KŠrAXӈ.Q޶؛{3`Pߍ{A]ף=`p#jCrĽiLp(VIߩW_g	1I}kU3B.̧/Lw|:aI֙fp'o֨%m?;y93jGL`aqzy5!aJBhMgSG/0N)H?AKrܰtt?xßuTQ}XKwoM˒`^xWYo6~_1o M\Nr N ]T쭱3aG@ؕ89RrB֍@=ڟJ()TJ+L/,}
)akkjV<:=_,^*vH.C'pWOBVdR:,:>a$b$dAYmVaeYlWE.uQ
Uy5"=bWj[{VRx7zTKL'H8wG-zoNbӪ\oV ~_Ro7KNb\>z$J}c;xQ#uqB7x5lrlhT[i}.5p*3y '$Zxw"YS&Yv
@aчFQ;z_ڱB۰AW Ldt9Q	{3}Pؘ\em09>q!ncܲ,tyzi081MEC!PIִg4ծmVGB-<=8 :*ܺ-~$/9z߬\Rq~F)#uj[xع-
f@Ƿ"^QBQm?$HP	/ߡŔX_n|҂˺r$7GS}fd-F9dhl%E1Dl)io'o^=M%׭R`D(Y
/ιBs[7rLu6Gz6ヌG\hPaOZu%tZ(|/u CSRG*x-> 0O:ޘ_쎍QrP̈\&1YS󐢁Z
?Ghǚ^e2[e#p~&{ڗ
)S kaDz_,&L 4wQ{QWq>(U54vƎGCd{1v$WsLn|WNzxD/֦
xr{
  "compilerOptions": {
    "lib": ["ESNext"],
    "module": "ESNext",
    "target": "ESNext",
    "moduleResolution": "Bundler",
    "moduleDetection": "force",
    "strict": true,
    "skipLibCheck": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true,
    "types": ["bun-types"]
  },
  "include": ["src/**/*", "test/**/*"]
}
nxP _2zߥ;ؿaB06xs]LB_x<6^33,a"JnE=dV+)G `x7 _!Z]6tdh8@B}s]]4erroPhGDpdCLx{OcB\|ƖfF&qUs 	x$ 40000 workflows vppȐDYN$x& 100644 deploy.yml 56Y"ǉYq/x"Qz#f%K< 20Qx  tvNu[[.+)8x;|yBȖ]~:_=K* ʿV,xG )h40000 docs G#MnJN=\\^ZewtvNu[[.+)4x& 40000 superpowers 4inҴ~YN ʠx@ 40000 plans ^p@mU+Q40000 specs I*9 йtBd;x }HH8ex JJ)x# QstvNu[[.+)9Im
x2!pbɍ(x  <G#MnJN=\\^ZePƠ.x= )zh.100644 docker-compose.yml %}5v=U[1f+kyx )z;9+x< )zɛuW*hl ;ںx0 S'?Q zoG9CH9!x +}Z'F̅T@T{ײ?"xI 100644 index.ts vQfzZx.v100644 schema.ts ҄ @H\p,F)3Ox? Y3g~F*9d:i40000 test Tc%8X
g蓌)m>Rx @?64N wCfsx Đ&-aJk^%|J;
7x Pinput: any = forceDocument
      ? { document: fileChunk, caption }
      : { photo: [fileChunk], caption };

    const result  ainput)x-%94x  F։E뽻*5H>x (![E*0x  YN1x͇*J5;dmH*x ar~7}; ݊[[x \ 

J=҈--9QG/o%
4ExI GF"	tele.asepharyana.tech/f/xYz123!
%},
  });
};
/xX 	)vwh|tq=V)03T@+iɨ΁xN40000 test 'Qi?u=N6)(OlXx p"vx 9100644 bot.ts ^@;^ZJP_40000 db }Z'F̅T@T{ײ100644 env.ts #/|Z+<%$Y c100644 index.ts m{JͺcoŞ958, 40000 routes /V7%kH\uN-6^40000 utils @?64N wCf5?Igkx 
,=7hVx nPv	k=x P{OI%x `}]"٣|-x ̯ %
T`7
Xx   "7_Xrgd)L=x !src/index.t+EVE]xV j}p}V	e&13=<MgN-40000 test {>R*ʄ
e)M$xQ ,&1.8.3/schema.json",
  "organizeImportsd,
0 RD
    }
  }
}
.gx 9100644 bot.ts 7->{س|40000 db iNnٟ MA|100644 env.ts v[#[u	'100644 index.ts oNwJ%	֮40000 routes N֪8TcOd840000 utils ="~ǽCkQ+VxMimport { Telegraf, type Context@'[2	!T(Nw)>mtry {
^ .message.document ? 'document' :!photo ? 'photo' :! ? 'video' :! ? 'audio' :!6oice ? 'voice' : 'animation';

        const fileObj =kX;const fileName =0.Z..0. ..9const maxSize = fileType === 'photo' ? 10 * 1024 * 1024 :!i ? 200 * 1024 * 1024 :!ivoice' ? 200 * 1024 * 1024$>%eRB
	$0	O	o	1	=	/
5I
i
=
 

#!2
        };

Y?7P%8
ax&= publicId, fileType, fileName, uploader: ctx.from.id });
    @7eF}
    }OL y!e! + '...'*gxI 100644 index.ts ֗`zh
@6~T100644 schema.ts %Sao/^9eR/ix	 dOx[ import { pgTable, text, bigind1*import type { InferSelectModel, InferInser*n{ x( 72K n! + '...' } });

[x\ 	$T(I&"&.${ handleFileRedirect, handleFileInfoS{<Q@0

    }
  }?}xn 100644 files.ts Gˁ-AL8Q=100644 health.ts An=@6wu6[100644 upload.ts |;krs'c\utuCx[*3x S/+/.".select().from(fileSchema)*0˳N'Location': redirectUrl
      }.select().from(fileSchema)*0{
&C e " $created_at:	# ? file.createdAtT	)
    }, { status: 2005	xJGx ?&##en88	}x j../*1	!5import { getFileType,{ Y$co@B
}<  ? 'document'ۓ@wyB
}몃4l	x h100644 file.ts (gB"Q&cc!100644 logger.ts gT`x]ɲU:SE100644 rateLimit.ts ܴĒgh"100644 telegram.ts i3ō,W,jP`i/9>
qx Smsg.document?.fileName ||.=     msg.voice?.fileName || msg.animation?.fileName || 'file'].% msg.audio?.mimeType ||
     o;
};
~73x3 ,M%!e#=Xl1wx`100644 bootstrap.test.ts Iɛxqb֦F0lpq4100644 bot.test.ts QKe-AeLfNuO100644 db.test.ts 'fOj^"ygg>100644 env.test.ts $$poWS{&h100644 file.test.ts 'Qρ9&z:100644 files.test.ts \Yqc>G0xOW100644 health.test.ts 2ыJٵcH1100644 rateLimit.test.ts W
2!n	10u100644 telegram.test.ts *
˵䍣F&4G100644 upload.test.ts }H?Sr?U(Q%] @x Bdescribe, it, expect, mock, beforeEach, afterAll } from "bun:test"Zxc@ Promise.resolve({
  stop: mock()
}));

mock.module("../src/bot"#)
}));

mock.module("../src/routes/upload"(
}));

mock.module("../src/routes/files").)
}));

mock.module("../src/routes/health"!+
}));

mock.module("../src/utils/rateLimit"*"
}));

describe("Bootstrap Server""-"!"../src/index""port""routes"3"/api/upload"3"/f/:public_id"3""3"/health");
  });
});
*=$x-! Idescribe, it, expect, mock, spyOn, beforeEach, afterAll } from "bun:test"a"../src/utils/logger"/"."#"-1003996572954""7"U
"telegraf"d#
}));
mock.module("../src/db/index"q)
  },
  files: {}G<&{
  telegramFileId: "stored_file_id",
1"stored_unique_id",
_
}));
mock.module(""3%"info""*"error");

describe("Telegram Bot Handler"P
"[$"6"../src/bot"r"start";I;"document", "photo", "video", "audio", "voice", "animation"g"should handle /start command"6"../src/bot"$+call => call[0] === "start"	c1	V"Halo"));
  });

  it("	."6"../src/bot"Ó(	"doc_123"P"doc_uniq_123"z2"application/pdf""cv.pdf"
        }y' 1["doc_123", "cv.pdf"YK="File berhasil diupload"), expect.any(Object));
  });

  it("`."6"../src/bot" {
          file_id: "photo_123"P"photo_uniq_123",
LI"image/jpeg"
        }]y' 1F[	"exceeds"l<*
Yx \"it, expect } from "bun:test">"../src/db/index"k'>"../src/db/schema";

describe("Database Layer", () => {
  it("""5"oi"%"T35y1xj! "3it, expect } from "bun:test";
import { getFileType,H0k } from "../src/utils/file";

describe("File Utilities", () => {
  describe("getFileType", () => {
    it(")" "video/mp4", "")).toBe("video");8"video/quicktime", "")).toBe("video");
    });

    it(")"!"audio/mpeg", "")).toBe("audio");2"audio/ogg", "")).toBe("audio");
    });

    it("i1")"image/jpeg", "my photo")).toBe("photo");."image/png", "cool image.png")).toBe("photo");-"image/gif", "funny.gif")).toBe("animation");?"image/png", "funny gif")).toBe("animation");
    });

    it("4"C"application/octet-stream", "this is a voice note")).toBe("voice");S"application/octet-stream", "cool animation")).toBe("animation");
    });

    it("0","application/pdf", "")).toBe("application");null, "")).toBe("document"!"checkFileSize", () => {
    it("'"6"photo".Y"video"5"+"%"photo".Z"video"6"9"%o	"unknown"F[	"unknown"*#"extractFileName", () => {
    it("		")"x-file-name": "custom.txt"0

	 "custom.txt");
    });

    it("	>"
&
$"doc.pdf" } }, null)).toBe("doc.pdf"

@"low.jpg" }, { fileName: "high.jpg" }] }, null)).toBe("high.jpg"
&"song.mp3" } }, null)).toBe("song.mp3"
d("voice.ogg" } }, null)).toBe("voice.ogg"
:"anim.gif" } }, null)).toBe("anim.gif");
    });

    it("+"

f"file"#"extractMimeType", () => {
    it("0")"x-mime-type": "text/plain"02 "text/plain");
    });

    it(" "?4"application/pdf" } }, null)).toBe("application/pdf""pE"image/jpeg" }, { mimeType: "image/png" }] }, null)).toBe("image/png"5*"audio/mpeg" } }, null)).toBe("audio/mpeg""R("audio/ogg" } }, null)).toBe("audio/ogg"#<"video/mp4" } }, null)).toBe("video/mp4");
    });

    it(","(W"application/octet-stream"vP]fx(-,Bdescribe, it, expect, mock, beforeEach, afterAll } from "bun:test"Z
    }))
  }))"../src/db/index"%)OA!"equals", value: val })
    }
  }Z<"photos/file_0.jpg" }));
mock.module("../src/utils/telegram"JH
    }
  })R"../src/utils/rateLimit"
/%
}));

describe("File Route Handlers"_", handleFileInfo"123456:ABC-DEF"\'"../src/routes/files"}&"handleFileRedirect", () => {
    it(";+"}R""!#	"test-id" )"Rate limit exceeded");
    });

    it("J2"}
          })
        })i+"""!#"missing-id"aSI$"File not found");
    });

    it("	5"}C{
              id: "uuid-123",
              publicId: "test-id",
P
"tg-file-id",

2"test.jpg"
            }])
          })
        })i+""!#	"test-id"aS#"Location")).toBe("A");
    });

    it("{0"}>"DB Connection Error",""!#	"test-id"a"Server error"@	""handleFileInfo", () => {
    it("J2"}
          })
        })i+"y"!#"missing-id"a&)I$"File not found");
    });

    it("x-"9G"test-id",
        fileName: "image.png",
        mimeType: "image/png"&-"photo"Z9"2026-05-18T00:00:00.000Z")
          })
        })i+"]"!#	"test-id"a&)8[I"test-id",
        file_name: "image.png",
        mime_type: "image/png"/"photo"2"2026-05-18T00:00:00.000Z"_"{0"}>"DB Connection Error","]"!#	"test-id"a&r"Server error"@CpyDx+
	8describe, it, expect, mock, beforeEach } from "bun:test"PZ"../src/db/index"+F
  }
}));

describe("Health Route Handler", () => {
  let handleHealth3`"../src/routes/health"<"2"-""+IL"ok"=":E"V""!""+"error""");
  });
});
ZY6Y^xn(describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
import logger from "../src/utils/logger";
import { config } from "../src/env"*
"telegraf"[{ file_id: "photo_id_low", file_unique_id: "unique_id_low" },
              { file_id: "photo_id_high", file_unique_id: "unique@_id_high" }
            ]
          }))
        };
      }
    })"info"","error");

describe("Telegram API Utilities" , getFileInfo, getBot`"../src/utils/telegram""getBot", () => {
    it("l'",+_$"forwardToStorage", () => {
    it("C<"0"fake photo data""test_photo.jpg""photo_id_high",Q
"unique_id_high"123456"File forwarded to storage"	,\	")"	8="Telegram send failed"&"fake photo data""test_photo.jpg"P"Telegram send failed"*With("!"	error: "Telegram send failed"%"getFileInfo", () => {
    it("5&"url, $	"getFile"&*D"some_file_id" }
          }))#	"getInfo"&*Jf c"image/jpeg",
              file_path: "photos/file_0.jpg"
            }
          })));
        }
9"Unknown URL"U6 "some_file_id", "some_unique_id"/+%4"image/jpeg",
        file_path: "photos/file_0.jpg"\	"L&".	ok: false,
""
      })))s#:"invalid_file_id", "invalid_unique_id")).rejects.toThrow(""@NxBdescribe, it, expect, mock, beforeEach, afterAll } from "bun:test"ZV"../src/db/index")
  },
  files: {}""nanoid";"mocked-nanoid-id"i*"../src/utils/telegram"){
    telegramFileId: "tg-file-id-123",
    telegramFileUniqueId: "tg-unique-id-abc",
    storageMessageId: 98765
  })),
  getBt6%{
        file_id: "tg-file-id-123",
!Rmime_type: "image/jpeg"
      }))
    }
  })
}));

describe("Upload Route Handler"o_""<"B7" 	,"a ", {
      method: "POST"C"content-type": "text/plain"
      },
      body: "plain text data"@"UQ");
  });

  it("0" 	,"a ", {
      method: "POST"""content-type": "application/json"AH"hello world").toString("base64"),
        fileName: "test.txt"
      })@Oz)!"mocked-nanoid-id"*"tg-file-id-123"!i"tg-unique-id-abc"#
"test.txt"#"document");
  });

  it("*" 	,"a ", {
      method: "POST"""content-type": "application/json"3Name: "test.txt"
      })@"Invalid JSON");
  });

  it("
," 	F)"multipart hello")], { type: "text/plain"%"file", fileBlob, "test_multi.txt");
/	"a /", {
      method: "POST",
      body: formData@OzI"mocked-nanoid-id"#"test_multi.txt";^rVx? Y3rv'1aZ^40000 test l>8g]d7YI)b+x 9100644 bot.js rP&8y\IJ_*m40000 db |Qc[/100644 env.js M g`'~06~>100644 index.js [J&(:J󛢳ИGx40000 routes jt	\ }[Y
cx40000 utils  QI>4jM&NnFLxDimport { Telegraf } from 'telegraf';
import logger from './utils/logger.js';
import { config } from './env.js';
import { db, files as fileSchema } from './db/index.js';
import { nanoid } from 'nanoid';
import { forwardToStorage } from './utils/telegram.js';

export const startBot = async () => {
  try {
    const bot = new Telegraf(config.botToken);

    bot.command('start', async (ctx) => {
      await ctx.reply(
        `👋 Halo! Kirimkan file (document, photo, video, audio, voice, animation) ke bot ini. ` +
        `File akan disimpan di private channel dan kamu dapat download link permanen.`
      );
    });

    bot.on(['document', 'photo', 'video', 'audio', 'voice', 'animation'], async (ctx) => {
      try {
        const fileType = ctx.message.document ? 'document' :
                        ctx.message.photo ? 'photo' :
                        ctx.message.video ? 'video' :
                        ctx.message.audio ? 'audio' :
                        ctx.message.voice ? 'voice' : 'animation';

        const fileObj = fileType === 'photo' ? ctx.message.photo.slice(-1)[0] : ctx.message[fileType];
        const { file_id, file_unique_id, file_size, mime_type } = fileObj;
        const fileName = ctx.message.document?.file_name ||
                        ctx.message.photo?.slice(-1)[0]?.file_name ||
                        ctx.message.video?.file_name ||
                        ctx.message.audio?.file_name ||
                        ctx.message.voice?.file_name ||
                        'file';

        const maxSize = fileType === 'photo' ? 10 * 1024 * 1024 :
                       fileType === 'audio' ? 200 * 1024 * 1024 :
                       fileType === 'voice' ? 200 * 1024 * 1024 : 2 * 1024 * 1024 * 1024;

        if (file_size > maxSize) {
          return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`);
        }

        const result = await forwardToStorage(file_id, fileName);
        const publicId = nanoid();

        const uploaded = {
          public_id: publicId,
          telegram_file_id: result.telegramFileId,
          telegram_file_unique_id: result.telegramFileUniqueId,
          storage_chat_id: config.storageChatId,
          storage_message_id: result.storageMessageId,
          file_name: fileName,
          mime_type: mime_type,
          size_bytes: file_size,
          file_type: fileType,
          uploader_id: ctx.from.id,
          created_at: new Date().toISOString(),
          updated_at: new Date().toISOString()
        };

        await db.insert(fileSchema).values(uploaded);

        const url = `${config.baseUrl}/f/${publicId}`;
        await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, {
          reply_parameters: { message_id: ctx.message.message_id }
        });

        logger.info('File uploaded via bot', { publicId, fileType, fileName, uploader: ctx.from.id });
      } catch (error) {
        logger.error('Bot file handler error', { error: error.message, chat_id: ctx.chat?.id });
        await ctx.reply('❌ Gagal mengupload file. Coba lagi nanti.');
      }
    });

    bot.use((ctx, next) => {
      logger.info('Telegram event received', { type: ctx.update.type, chat_id: ctx.chat?.id });
      return next();
    });

    await bot.launch();

    logger.info('Telegram bot started', { botToken: config.botToken?.substring(0, 10) + '...' });

    return bot;
  } catch (error) {
    logger.error('Failed to start bot', { error: error.message });
    throw error;
  }
};
3SxI 100644 index.js A0q+I,WXVe100644 schema.js ">chF}$མӿxoimport { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import logger from '../utils/logger.js';
import { files } from './schema.js';

const client = postgres(process.env.DATABASE_URL, {
  max: 10,
  idle_timeout: 20,
  connect_timeout: 10
});

export const db = drizzle(client, { schema: { files } });
export { files };
export default db;}t6xfimport { pgTable, text, bigint, timestamp, uuid } from 'drizzle-orm/pg-core';

export const files = pgTable('files', {
  id: uuid('id').primaryKey().defaultRandom(),
  publicId: text('public_id').unique().notNull(),
  telegramFileId: text('telegram_file_id').notNull(),
  telegramFileUniqueId: text('telegram_file_unique_id').notNull(),
  storageChatId: bigint('storage_chat_id', { mode: 'number' }).notNull(),
  storageMessageId: bigint('storage_message_id', { mode: 'number' }).notNull(),
  fileName: text('file_name').notNull(),
  mimeType: text('mime_type').notNull(),
  sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
  fileType: text('file_type').notNull(),
  uploaderId: bigint('uploader_id', { mode: 'number' }).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull()
});!'dJx[import logger from './utils/logger.js';

const requiredEnv = {
  BOT_TOKEN: process.env.BOT_TOKEN,
  STORAGE_CHANNEL_ID: process.env.STORAGE_CHANNEL_ID,
  BASE_URL: process.env.BASE_URL,
  DATABASE_URL: process.env.DATABASE_URL,
  PORT: process.env.PORT
};

const missing = Object.entries(requiredEnv)
  .filter(([_, value]) => value === undefined || value === '')
  .map(([key]) => key);

if (missing.length > 0) {
  logger.error('Missing required environment variables:', missing);
  throw new Error(`Missing environment variables: ${missing.join(', ')}`);
}

export const config = {
  botToken: process.env.BOT_TOKEN,
  storageChatId: parseInt(process.env.STORAGE_CHANNEL_ID, 10),
  baseUrl: process.env.BASE_URL,
  databaseUrl: process.env.DATABASE_URL,
  port: parseInt(process.env.PORT, 10) || 3000,
  nodeEnv: process.env.NODE_ENV || 'development',
  logLevel: process.env.LOG_LEVEL || 'info',
  rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000,
  rateLimitMaxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 30
};

logger.info('Environment variables loaded', { config: { ...config, botToken: config.botToken?.substring(0, 10) + '...' } });
Oximport { serve } from 'bun';
import logger from './utils/logger.js';
import { config } from './env.js';
import { startBot } from './bot.js';
import { handleUpload } from './routes/upload.js';
import { handleFileRedirect, handleFileInfo } from './routes/files.js';
import { handleHealth } from './routes/health.js';
import { cleanupRateLimitCache } from './utils/rateLimit.js';

const server = serve({
  port: config.port,
  routes: {
    '/api/upload': {
      POST: handleUpload
    },
    '/f/:public_id': {
      GET: handleFileRedirect
    },
    '/file/:public_id/info': {
      GET: handleFileInfo
    },
    '/health': {
      GET: handleHealth
    }
  }
});

const bot = await startBot();

logger.info('Server started', { port: config.port, url: config.baseUrl });

const gracefulShutdown = async (signal) => {
  logger.info('Graceful shutdown signal received', { signal });

  logger.info('Closing HTTP server');
  server.stop();

  logger.info('Stopping Telegram bot');
  await bot.stop();

  logger.info('Server shutdown complete');
  process.exit(0);
};

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

setInterval(cleanupRateLimitCache, 60000);

logger.info('Application running successfully');
Eekxn 100644 files.js w,#Sh>x&100644 health.js TBus<U?100644 upload.js )Y )ap5!j,Vx6	import logger from '../utils/logger.js';
import { db, files as fileSchema } from '../db/index.js';
import { checkRateLimit } from '../utils/rateLimit.js';
import { eq } from 'drizzle-orm';

export const handleFileRedirect = async (req, ctx) => {
  const public_id = ctx?.params?.public_id;
  try {
    const ip = req.headers.get('x-forwarded-for') || '127.0.0.1';

    if (!public_id || !checkRateLimit(ip)) {
      return Response.json({ error: 'Rate limit exceeded' }, { status: 429 });
    }

    const result = await db.select().from(fileSchema).where(eq(fileSchema.publicId, public_id)).limit(1);

    if (!result.length) {
      logger.warn('File not found', { public_id });
      return Response.json({ error: 'File not found' }, { status: 404 });
    }

    const file = result[0];
    const { getBot } = await import('../utils/telegram.js');
    const bot = getBot();
    const fileInfo = await bot.api.getFile(file.telegramFileId);

    const redirectUrl = `https://api.telegram.org/file/bot${process.env.BOT_TOKEN}/${fileInfo.file_path}`;
    return new Response(null, {
      status: 302,
      headers: {
        'Location': redirectUrl
      }
    });
  } catch (error) {
    logger.error('File redirect error', { public_id, error: error.message });
    return Response.json({ error: 'Server error' }, { status: 500 });
  }
};

export const handleFileInfo = async (req, ctx) => {
  const public_id = ctx?.params?.public_id;
  try {
    if (!public_id) {
      return Response.json({ error: 'Missing file id' }, { status: 400 });
    }

    const result = await db.select().from(fileSchema).where(eq(fileSchema.publicId, public_id)).limit(1);

    if (!result.length) {
      logger.warn('File not found', { public_id });
      return Response.json({ error: 'File not found' }, { status: 404 });
    }

    const file = result[0];
    return Response.json({
      public_id: file.publicId,
      file_name: file.fileName,
      mime_type: file.mimeType,
      size_bytes: file.sizeBytes,
      file_type: file.fileType,
      uploader_id: file.uploaderId,
      created_at: file.createdAt.toISOString ? file.createdAt.toISOString() : file.createdAt
    }, { status: 200 });
  } catch (error) {
    logger.error('File info error', { public_id, error: error.message });
    return Response.json({ error: 'Server error' }, { status: 500 });
  }
};
	Dx a4I#sql+DHealth = async (req) => {
  try {
    await db.execute(sql`SELECT 1`status: 'ok'u<Health check failed', {5status: 'error'""3xoimport logger from '../utils/logger.js';
import { db, files as fileSchema } from '../db/index.js';
import { nanoid } from 'nanoid';
import { forwardToStorage, getBot } from '../utils/telegram.js';
import { getFileType, checkFileSize, extractFileName, extractMimeType } from '../utils/file.js';
import { config } from '../env.js';

export const handleUpload = async (req) => {
  try {
    const contentType = req.headers.get('content-type') || '';

    if (contentType.includes('multipart/form-data')) {
      return handleMultipartUpload(req);
    } else if (contentType.includes('application/json')) {
      return handleJSONUpload(req);
    }

    return Response.json(
      { error: 'Unsupported content type. Use multipart/form-data or application/json' },
      { status: 400 }
    );
  } catch (error) {
    logger.error('Upload error', { error: error.message });
    return Response.json({ error: error.message }, { status: 500 });
  }
};

const handleMultipartUpload = async (req) => {
  try {
    const formData = await req.formData();
    const file = formData.get('file');
    const fileName = formData.get('fileName') || (file instanceof File ? file.name : null) || 'file';

    if (!file || !(file instanceof File)) {
      return Response.json({ error: 'No file provided' }, { status: 400 });
    }

    const fileBytes = await file.arrayBuffer();
    const fileBuffer = Buffer.from(fileBytes);
    const mimeType = file.type || extractMimeType({}, req) || 'application/octet-stream';
    const fileType = getFileType(mimeType, fileName);

    if (!checkFileSize(fileBuffer.byteLength, fileType)) {
      return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 });
    }

    const isDocument = fileName.endsWith('.pdf') || fileName.endsWith('.txt') || !['photo', 'video', 'audio', 'voice', 'animation'].includes(fileType);
    const result = await forwardToStorage(fileBuffer, fileName, isDocument);
    const bot = getBot();
    const fileInfo = await bot.api.getFile(result.telegramFileId);

    const uploaded = {
      publicId: nanoid(),
      telegramFileId: result.telegramFileId,
      telegramFileUniqueId: result.telegramFileUniqueId,
      storageChatId: config.storageChatId,
      storageMessageId: result.storageMessageId,
      fileName: fileName,
      mimeType: fileInfo.mime_type || mimeType || 'application/octet-stream',
      sizeBytes: fileInfo.file_size || fileBuffer.byteLength,
      fileType: fileType,
      uploaderId: 0,
      createdAt: new Date(),
      updatedAt: new Date()
    };

    await db.insert(fileSchema).values(uploaded);

    // Prepare response matching original snake_case fields as expected in task description
    const responsePayload = {
      public_id: uploaded.publicId,
      telegram_file_id: uploaded.telegramFileId,
      telegram_file_unique_id: uploaded.telegramFileUniqueId,
      storage_chat_id: uploaded.storageChatId,
      storage_message_id: uploaded.storageMessageId,
      file_name: uploaded.fileName,
      mime_type: uploaded.mimeType,
      size_bytes: uploaded.sizeBytes,
      file_type: uploaded.fileType,
      uploader_id: uploaded.uploaderId,
      created_at: uploaded.createdAt.toISOString(),
      download_url: `${config.baseUrl}/f/${uploaded.publicId}`
    };

    return Response.json(responsePayload, { status: 200 });
  } catch (error) {
    logger.error('Multipart upload error', { error: error.message });
    return Response.json({ error: error.message }, { status: 500 });
  }
};

const handleJSONUpload = async (req) => {
  try {
    const { file, fileName = 'file' } = await req.json();

    if (!file || typeof file !== 'string') {
      return Response.json(
        { error: 'Invalid JSON. Must include "file" (base64) and optional "fileName"' },
        { status: 400 }
      );
    }

    const fileBytes = Buffer.from(file, 'base64');
    const mimeType = 'application/octet-stream';
    const fileType = getFileType(mimeType, fileName) === 'application' ? 'document' : getFileType(mimeType, fileName);

    if (!checkFileSize(fileBytes.byteLength, fileType)) {
      return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 });
    }

    const isDocument = fileName.endsWith('.pdf') || fileName.endsWith('.txt') || !['photo', 'video', 'audio', 'voice', 'animation'].includes(fileType);
    const result = await forwardToStorage(fileBytes, fileName, isDocument);
    const bot = getBot();
    const fileInfo = await bot.api.getFile(result.telegramFileId);

    const uploaded = {
      publicId: nanoid(),
      telegramFileId: result.telegramFileId,
      telegramFileUniqueId: result.telegramFileUniqueId,
      storageChatId: config.storageChatId,
      storageMessageId: result.storageMessageId,
      fileName: fileName,
      mimeType: fileInfo.mime_type || mimeType || 'application/octet-stream',
      sizeBytes: fileInfo.file_size || fileBytes.byteLength,
      fileType: fileType,
      uploaderId: 0,
      createdAt: new Date(),
      updatedAt: new Date()
    };

    await db.insert(fileSchema).values(uploaded);

    // Prepare response matching original snake_case fields as expected in task description
    const responsePayload = {
      public_id: uploaded.publicId,
      telegram_file_id: uploaded.telegramFileId,
      telegram_file_unique_id: uploaded.telegramFileUniqueId,
      storage_chat_id: uploaded.storageChatId,
      storage_message_id: uploaded.storageMessageId,
      file_name: uploaded.fileName,
      mime_type: uploaded.mimeType,
      size_bytes: uploaded.sizeBytes,
      file_type: uploaded.fileType,
      uploader_id: uploaded.uploaderId,
      created_at: uploaded.createdAt.toISOString(),
      download_url: `${config.baseUrl}/f/${uploaded.publicId}`
    };

    return Response.json(responsePayload, { status: 200 });
  } catch (error) {
    logger.error('JSON upload error', { error: error.message });
    return Response.json({ error: error.message }, { status: 500 });
  }
};
Dt	x h100644 file.js .%L$PESO
~100644 logger.js gT`x]ɲU:SE100644 rateLimit.js 
ŵ_}gM4100644 telegram.js 3:ߎْZ	I;ްcx0const FILE_TYPES = {
  document: 2 * 1024 * 1024 * 1024, // 2GB
  photo: 10 * 1024 * 1024, // 10MB
  video: 2 * 1024 * 1024 * 1024, // 2GB
  audio: 200 * 1024 * 1024, // 200MB
  voice: 200 * 1024 * 1024, // 200MB
  animation: 2 * 1024 * 1024 * 1024 // 2GB
};

export const getFileType = (mime, caption) => {
  const mimeUpper = mime?.split('/')[0]?.toLowerCase();
  const captionLower = caption?.toLowerCase();

  if (mimeUpper === 'video') return 'video';
  if (mimeUpper === 'audio') return 'audio';
  if (mimeUpper === 'document') return 'document';
  if (mimeUpper === 'image') return captionLower?.includes('gif') ? 'animation' : 'photo';
  if (captionLower?.includes('voice')) return 'voice';
  if (captionLower?.includes('animation')) return 'animation';

  return mimeUpper || 'document';
};

export const checkFileSize = (sizeBytes, fileType) => {
  const limit = FILE_TYPES[fileType] || FILE_TYPES.document;
  return sizeBytes <= limit;
};

export const extractFileName = (msg, request) => {
  if (request?.headers?.['x-file-name']) {
    return request.headers['x-file-name'];
  }
  return msg.document?.fileName || msg.photo?.slice(-1)[0]?.fileName || msg.audio?.fileName ||
         msg.voice?.fileName || msg.animation?.fileName || 'file';
};

export const extractMimeType = (msg, request) => {
  if (request?.headers?.['x-mime-type']) {
    return request.headers['x-mime-type'];
  }
  return msg.document?.mimeType || msg.photo?.slice(-1)[0]?.mimeType || msg.audio?.mimeType ||
         msg.voice?.mimeType || msg.animation?.mimeType || 'application/octet-stream';
};
<Cx1import logger from './logger.js';

const rateLimitMap = new Map();

export const checkRateLimit = (key) => {
  const now = Date.now();
  const windowMs = parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000;
  const maxRequests = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 30;

  if (!rateLimitMap.has(key)) {
    rateLimitMap.set(key, { count: 0, reset: now + windowMs });
  }

  const record = rateLimitMap.get(key);

  if (now > record.reset) {
    record.count = 0;
    record.reset = now + windowMs;
  }

  if (record.count >= maxRequests) {
    logger.warn('Rate limit exceeded', { key, count: record.count, reset: record.reset });
    return false;
  }

  record.count++;
  return true;
};

export const cleanupRateLimitCache = () => {
  const now = Date.now();
  const windowMs = parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000;
  const keysToDelete = [];

  for (const [key, record] of rateLimitMap.entries()) {
    if (now > record.reset) {
      keysToDelete.push(key);
    }
  }

  keysToDelete.forEach(key => rateLimitMap.delete(key));
};
AW!}x$import { Telegraf } from 'telegraf';
import logger from './logger.js';
import { config } from '../env.js';

const bot = new Telegraf(config.botToken);
const TELEGRAM_API_URL = `https://api.telegram.org/bot${config.botToken}/`;

export const forwardToStorage = async (fileChunk, fileName, forceDocument = false) => {
  try {
    const caption = forceDocument ? `📁 ${fileName}` : fileName;
    const input = forceDocument ? { document: fileChunk, caption } : { photo: [fileChunk], caption };

    const result = await bot.api.sendPhoto(config.storageChatId, input);

    logger.info('File forwarded to storage', { fileName, message: result.message_id });

    return {
      telegramFileId: result.photo?.slice(-1)[0]?.file_id,
      telegramFileUniqueId: result.photo?.slice(-1)[0]?.file_unique_id,
      storageMessageId: result.message_id
    };
  } catch (error) {
    logger.error('Failed to forward file to storage', { fileName, error: error.message });
    throw error;
  }
};

export const getFileInfo = async (telegramFileId, telegramFileUniqueId) => {
  try {
    const result = await fetch(`${TELEGRAM_API_URL}getFile`);
    const data = await result.json();

    if (!data.ok) {
      throw new Error(data.description || 'Telegram API error');
    }

    const fileId = data.result.file_id === telegramFileId ? telegramFileId : telegramFileUniqueId;
    const fileResult = await fetch(`${TELEGRAM_API_URL}getInfo`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ file_id: fileId })
    });
    const fileInfo = await fileResult.json();

    if (!fileInfo.ok) {
      throw new Error(fileInfo.description || 'Telegram info error');
    }

    return {
      file_size: fileInfo.result.file_size,
      mime_type: fileInfo.result.mime_type,
      file_path: fileInfo.result.file_path
    };
  } catch (error) {
    logger.error('Failed to get file info', { error: error.message });
    throw error;
  }
};

export const getBot = () => bot;
[(x`100644 bootstrap.test.js 'sRݵaX3.8100644 bot.test.js 'y<v}GA@
d100644 db.test.js tVɧi^AmgF100644 env.test.js g3S.s(D_ B%100644 file.test.js d4#4b2c0100644 files.test.js wD3ݳO
100644 health.test.js {h2cǈ\7\|100644 rateLimit.test.js 7p	x~ٸ100644 telegram.test.js 9p+	q100644 upload.test.js .-?%%=_-3o?ʦ1aximport { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";

const mockServe = mock((options) => {
  return {
    port: options.port,
    routes: options.routes,
    stop: mock()
  };
});

const originalServe = Bun.serve;
Bun.serve = mockServe;

const mockStartBot = mock(() => Promise.resolve({
  stop: mock()
}));

mock.module("../src/bot.js", () => ({
  startBot: mockStartBot
}));

mock.module("../src/routes/upload.js", () => ({
  handleUpload: mock()
}));

mock.module("../src/routes/files.js", () => ({
  handleFileRedirect: mock(),
  handleFileInfo: mock()
}));

mock.module("../src/routes/health.js", () => ({
  handleHealth: mock()
}));

mock.module("../src/utils/rateLimit.js", () => ({
  cleanupRateLimitCache: mock()
}));

describe("Bootstrap Server", () => {
  beforeEach(() => {
    mockServe.mockClear();
    mockStartBot.mockClear();
  });

  afterAll(() => {
    Bun.serve = originalServe;
  });

  it("should bootstrap the application successfully", async () => {
    await import("../src/index.js");

    expect(mockServe).toHaveBeenCalled();
    expect(mockStartBot).toHaveBeenCalled();

    const serveCallArgs = mockServe.mock.calls[0][0];
    expect(serveCallArgs).toHaveProperty("port");
    expect(serveCallArgs).toHaveProperty("routes");
    expect(serveCallArgs.routes).toHaveProperty("/api/upload");
    expect(serveCallArgs.routes).toHaveProperty("/f/:public_id");
    expect(serveCallArgs.routes).toHaveProperty("/file/:public_id/info");
    expect(serveCallArgs.routes).toHaveProperty("/health");
  });
});
¾x^import { describe, it, expect, mock, spyOn, beforeEach, afterAll } from "bun:test";
import logger from "../src/utils/logger.js";

// Mock environment
process.env.BOT_TOKEN = "8605908810:AAFpUzlIBktfd_7wpEj7zMJob2CFxvG-ZGY";
process.env.STORAGE_CHANNEL_ID = "-1003996572954";
process.env.BASE_URL = "https://tele.asepharyana.tech";

// Mock Telegraf
const mockLaunch = mock(() => Promise.resolve());
const mockCommand = mock();
const mockOn = mock();
const mockUse = mock();

mock.module("telegraf", () => {
  return {
    Telegraf: class {
      constructor(token) {
        this.token = token;
        this.launch = mockLaunch;
        this.command = mockCommand;
        this.on = mockOn;
        this.use = mockUse;
      }
    }
  };
});

// Mock database
const mockInsert = mock(() => ({
  values: mock(() => Promise.resolve())
}));
mock.module("../src/db/index.js", () => ({
  db: {
    insert: mockInsert
  },
  files: {}
}));

// Mock forwardToStorage
const mockForwardToStorage = mock(() => Promise.resolve({
  telegramFileId: "stored_file_id",
  telegramFileUniqueId: "stored_unique_id",
  storageMessageId: 9999
}));
mock.module("../src/utils/telegram.js", () => ({
  forwardToStorage: mockForwardToStorage
}));

const infoSpy = spyOn(logger, "info");
const errorSpy = spyOn(logger, "error");

describe("Telegram Bot Handler", () => {
  beforeEach(() => {
    mockLaunch.mockClear();
    mockCommand.mockClear();
    mockOn.mockClear();
    mockUse.mockClear();
    mockInsert.mockClear();
    mockForwardToStorage.mockClear();
    infoSpy.mockClear();
    errorSpy.mockClear();
  });

  it("should initialize and launch the bot", async () => {
    const { startBot } = await import("../src/bot.js");
    const bot = await startBot();

    expect(bot).toBeDefined();
    expect(mockCommand).toHaveBeenCalledWith("start", expect.any(Function));
    expect(mockOn).toHaveBeenCalledWith(
      ["document", "photo", "video", "audio", "voice", "animation"],
      expect.any(Function)
    );
    expect(mockUse).toHaveBeenCalled();
    expect(mockLaunch).toHaveBeenCalled();
  });

  it("should handle /start command", async () => {
    const { startBot } = await import("../src/bot.js");
    await startBot();

    const startHandler = mockCommand.mock.calls.find(call => call[0] === "start")[1];
    const replyMock = mock(() => Promise.resolve());
    const ctx = {
      reply: replyMock
    };

    await startHandler(ctx);
    expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("Halo"));
  });

  it("should process document uploads and save to db", async () => {
    const { startBot } = await import("../src/bot.js");
    await startBot();

    const fileHandler = mockOn.mock.calls[0][1];
    const replyMock = mock(() => Promise.resolve());
    const ctx = {
      message: {
        message_id: 42,
        document: {
          file_id: "doc_123",
          file_unique_id: "doc_uniq_123",
          file_size: 1024,
          mime_type: "application/pdf",
          file_name: "cv.pdf"
        }
      },
      from: {
        id: 999
      },
      reply: replyMock
    };

    await fileHandler(ctx);
    expect(mockForwardToStorage).toHaveBeenCalledWith("doc_123", "cv.pdf");
    expect(mockInsert).toHaveBeenCalled();
    expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("File berhasil diupload"), expect.any(Object));
  });

  it("should reject uploads exceeding max size limit", async () => {
    const { startBot } = await import("../src/bot.js");
    await startBot();

    const fileHandler = mockOn.mock.calls[0][1];
    const replyMock = mock(() => Promise.resolve());
    const ctx = {
      message: {
        message_id: 42,
        photo: [{
          file_id: "photo_123",
          file_unique_id: "photo_uniq_123",
          file_size: 20 * 1024 * 1024, // 20MB exceeds 10MB limit
          mime_type: "image/jpeg"
        }]
      },
      from: {
        id: 999
      },
      reply: replyMock
    };

    await fileHandler(ctx);
    expect(mockForwardToStorage).not.toHaveBeenCalled();
    expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("exceeds"));
  });

  afterAll(() => {
    mock.restore();
  });
});
gyDxJimport { describe, it, expect } from "bun:test";
import { db, files } from "../src/db/index.js";
import { files as schemaFiles } from "../src/db/schema.js";

describe("Database Layer", () => {
  it("should export db instance", () => {
    expect(db).toBeDefined();
  });

  it("should export files schema from both index and schema", () => {
    expect(files).toBeDefined();
    expect(schemaFiles).toBeDefined();
    expect(files).toBe(schemaFiles);
  });

  it("should have correct schema properties", () => {
    expect(files.id).toBeDefined();
    expect(files.publicId).toBeDefined();
    expect(files.telegramFileId).toBeDefined();
    expect(files.telegramFileUniqueId).toBeDefined();
    expect(files.storageChatId).toBeDefined();
    expect(files.storageMessageId).toBeDefined();
    expect(files.fileName).toBeDefined();
    expect(files.mimeType).toBeDefined();
    expect(files.sizeBytes).toBeDefined();
    expect(files.fileType).toBeDefined();
    expect(files.uploaderId).toBeDefined();
    expect(files.createdAt).toBeDefined();
    expect(files.updatedAt).toBeDefined();
  });
});
f#gxzimport { describe, it, expect, beforeAll } from "bun:test";
import { config } from "../src/env.js";

describe("Environment Variables Validation", () => {
  it("config should have all required fields", () => {
    expect(config).toHaveProperty("botToken");
    expect(config).toHaveProperty("storageChatId");
    expect(config).toHaveProperty("baseUrl");
    expect(config).toHaveProperty("databaseUrl");
    expect(config).toHaveProperty("port");
    expect(config).toHaveProperty("nodeEnv");
    expect(config).toHaveProperty("logLevel");
    expect(config).toHaveProperty("rateLimitWindowMs");
    expect(config).toHaveProperty("rateLimitMaxRequests");
  });

  it("config.botToken should return BOT_TOKEN from process.env", () => {
    expect(config.botToken).toBe(process.env.BOT_TOKEN);
  });

  it("config.storageChatId should be parsed as integer from STORAGE_CHANNEL_ID", () => {
    expect(typeof config.storageChatId).toBe("number");
    expect(config.storageChatId).toBe(parseInt(process.env.STORAGE_CHANNEL_ID, 10));
  });

  it("config.port should default to 3000 when not specified", () => {
    expect(typeof config.port).toBe("number");
  });

  it("nodeEnv should be 'test' or 'development'", () => {
    expect(["test", "development"]).toContain(config.nodeEnv);
  });

  it("logLevel should default to 'info'", () => {
    expect(config.logLevel).toBe("info");
  });

  it("rateLimitWindowMs should default to 60000 when not specified", () => {
    expect(config.rateLimitWindowMs).toBe(60000);
  });

  it("rateLimitMaxRequests should default to 30 when not specified", () => {
    expect(config.rateLimitMaxRequests).toBe(30);
  });
});
)AxVimport { describe, it, expect } from "bun:test";
import { getFileType, checkFileSize, extractFileName, extractMimeType } from "../src/utils/file.js";

describe("File Utilities", () => {
  describe("getFileType", () => {
    it("should classify video mime types as video", () => {
      expect(getFileType("video/mp4", "")).toBe("video");
      expect(getFileType("video/quicktime", "")).toBe("video");
    });

    it("should classify audio mime types as audio", () => {
      expect(getFileType("audio/mpeg", "")).toBe("audio");
      expect(getFileType("audio/ogg", "")).toBe("audio");
    });

    it("should classify image mime types based on caption", () => {
      expect(getFileType("image/jpeg", "my photo")).toBe("photo");
      expect(getFileType("image/png", "cool image.png")).toBe("photo");
      expect(getFileType("image/gif", "funny.gif")).toBe("animation");
      expect(getFileType("image/png", "funny gif")).toBe("animation");
    });

    it("should classify voice and animation based on caption", () => {
      expect(getFileType("application/octet-stream", "this is a voice note")).toBe("voice");
      expect(getFileType("application/octet-stream", "cool animation")).toBe("animation");
    });

    it("should default to mime first segment or document", () => {
      expect(getFileType("application/pdf", "")).toBe("application");
      expect(getFileType(null, "")).toBe("document");
    });
  });

  describe("checkFileSize", () => {
    it("should allow files under the size limit", () => {
      expect(checkFileSize(5 * 1024 * 1024, "photo")).toBe(true); // Photo limit is 10MB
      expect(checkFileSize(1 * 1024 * 1024 * 1024, "video")).toBe(true); // Video limit is 2GB
    });

    it("should block files exceeding the size limit", () => {
      expect(checkFileSize(15 * 1024 * 1024, "photo")).toBe(false); // Photo limit is 10MB
      expect(checkFileSize(3 * 1024 * 1024 * 1024, "video")).toBe(false); // Video limit is 2GB
    });

    it("should fall back to document limit if fileType is unknown", () => {
      expect(checkFileSize(1 * 1024 * 1024 * 1024, "unknown")).toBe(true); // Document limit is 2GB
      expect(checkFileSize(3 * 1024 * 1024 * 1024, "unknown")).toBe(false);
    });
  });

  describe("extractFileName", () => {
    it("should extract file name from headers if present", () => {
      const req = { headers: { "x-file-name": "custom.txt" } };
      expect(extractFileName({}, req)).toBe("custom.txt");
    });

    it("should extract file name from various message attachment types", () => {
      expect(extractFileName({ document: { fileName: "doc.pdf" } }, null)).toBe("doc.pdf");
      expect(extractFileName({ photo: [{ fileName: "low.jpg" }, { fileName: "high.jpg" }] }, null)).toBe("high.jpg");
      expect(extractFileName({ audio: { fileName: "song.mp3" } }, null)).toBe("song.mp3");
      expect(extractFileName({ voice: { fileName: "voice.ogg" } }, null)).toBe("voice.ogg");
      expect(extractFileName({ animation: { fileName: "anim.gif" } }, null)).toBe("anim.gif");
    });

    it("should return default filename if not found", () => {
      expect(extractFileName({}, null)).toBe("file");
    });
  });

  describe("extractMimeType", () => {
    it("should extract mime type from headers if present", () => {
      const req = { headers: { "x-mime-type": "text/plain" } };
      expect(extractMimeType({}, req)).toBe("text/plain");
    });

    it("should extract mime type from various message attachment types", () => {
      expect(extractMimeType({ document: { mimeType: "application/pdf" } }, null)).toBe("application/pdf");
      expect(extractMimeType({ photo: [{ mimeType: "image/jpeg" }, { mimeType: "image/png" }] }, null)).toBe("image/png");
      expect(extractMimeType({ audio: { mimeType: "audio/mpeg" } }, null)).toBe("audio/mpeg");
      expect(extractMimeType({ voice: { mimeType: "audio/ogg" } }, null)).toBe("audio/ogg");
      expect(extractMimeType({ animation: { mimeType: "video/mp4" } }, null)).toBe("video/mp4");
    });

    it("should return default mime type if not found", () => {
      expect(extractMimeType({}, null)).toBe("application/octet-stream");
    });
  });
});
Pcx-import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";

// Mock database layer
const mockSelect = mock(() => ({
  from: mock(() => ({
    where: mock(() => ({
      limit: mock(() => Promise.resolve([]))
    }))
  }))
}));

mock.module("../src/db/index.js", () => ({
  db: {
    select: mockSelect
  },
  files: {
    publicId: {
      equals: (val) => ({ type: "equals", value: val })
    }
  }
}));

// Mock telegram utils
const mockGetFile = mock(() => Promise.resolve({ file_path: "photos/file_0.jpg" }));
mock.module("../src/utils/telegram.js", () => ({
  getBot: () => ({
    api: {
      getFile: mockGetFile
    }
  })
}));

// Mock rateLimit
const mockCheckRateLimit = mock(() => true);
mock.module("../src/utils/rateLimit.js", () => ({
  checkRateLimit: mockCheckRateLimit
}));

describe("File Route Handlers", () => {
  let handleFileRedirect, handleFileInfo;

  beforeEach(async () => {
    mockSelect.mockClear();
    mockGetFile.mockClear();
    mockCheckRateLimit.mockClear();

    // Set up mock token
    process.env.BOT_TOKEN = "123456:ABC-DEF";

    const filesRoute = await import("../src/routes/files.js");
    handleFileRedirect = filesRoute.handleFileRedirect;
    handleFileInfo = filesRoute.handleFileInfo;
  });

  describe("handleFileRedirect", () => {
    it("should return 429 if rate limit is exceeded", async () => {
      mockCheckRateLimit.mockImplementationOnce(() => false);
      const req = new Request("http://localhost:3000/f/test-id");

      const res = await handleFileRedirect(req, { params: { public_id: "test-id" } });
      expect(res.status).toBe(429);
      const body = await res.json();
      expect(body.error).toBe("Rate limit exceeded");
    });

    it("should return 404 if file is not found in database", async () => {
      mockSelect.mockImplementationOnce(() => ({
        from: () => ({
          where: () => ({
            limit: () => Promise.resolve([])
          })
        })
      }));

      const req = new Request("http://localhost:3000/f/missing-id");
      const res = await handleFileRedirect(req, { params: { public_id: "missing-id" } });
      expect(res.status).toBe(404);
      const body = await res.json();
      expect(body.error).toBe("File not found");
    });

    it("should redirect to telegram file url if file is found", async () => {
      mockSelect.mockImplementationOnce(() => ({
        from: () => ({
          where: () => ({
            limit: () => Promise.resolve([{
              id: "uuid-123",
              publicId: "test-id",
              telegramFileId: "tg-file-id",
              fileName: "test.jpg"
            }])
          })
        })
      }));

      const req = new Request("http://localhost:3000/f/test-id");
      const res = await handleFileRedirect(req, { params: { public_id: "test-id" } });
      expect(res.status).toBe(302);
      expect(res.headers.get("Location")).toBe("https://api.telegram.org/file/bot123456:ABC-DEF/photos/file_0.jpg");
    });

    it("should return 500 on database or external errors", async () => {
      mockSelect.mockImplementationOnce(() => {
        throw new Error("DB Connection Error");
      });

      const req = new Request("http://localhost:3000/f/test-id");
      const res = await handleFileRedirect(req, { params: { public_id: "test-id" } });
      expect(res.status).toBe(500);
      const body = await res.json();
      expect(body.error).toBe("Server error");
    });
  });

  describe("handleFileInfo", () => {
    it("should return 404 if file is not found in database", async () => {
      mockSelect.mockImplementationOnce(() => ({
        from: () => ({
          where: () => ({
            limit: () => Promise.resolve([])
          })
        })
      }));

      const req = new Request("http://localhost:3000/file/missing-id/info");
      const res = await handleFileInfo(req, { params: { public_id: "missing-id" } });
      expect(res.status).toBe(404);
      const body = await res.json();
      expect(body.error).toBe("File not found");
    });

    it("should return file info JSON if file is found", async () => {
      const dbFile = {
        publicId: "test-id",
        fileName: "image.png",
        mimeType: "image/png",
        sizeBytes: 2048,
        fileType: "photo",
        uploaderId: 99999,
        createdAt: new Date("2026-05-18T00:00:00.000Z")
      };

      mockSelect.mockImplementationOnce(() => ({
        from: () => ({
          where: () => ({
            limit: () => Promise.resolve([dbFile])
          })
        })
      }));

      const req = new Request("http://localhost:3000/file/test-id/info");
      const res = await handleFileInfo(req, { params: { public_id: "test-id" } });
      expect(res.status).toBe(200);
      const body = await res.json();
      expect(body).toEqual({
        public_id: "test-id",
        file_name: "image.png",
        mime_type: "image/png",
        size_bytes: 2048,
        file_type: "photo",
        uploader_id: 99999,
        created_at: "2026-05-18T00:00:00.000Z"
      });
    });

    it("should return 500 on database or external errors", async () => {
      mockSelect.mockImplementationOnce(() => {
        throw new Error("DB Connection Error");
      });

      const req = new Request("http://localhost:3000/file/test-id/info");
      const res = await handleFileInfo(req, { params: { public_id: "test-id" } });
      expect(res.status).toBe(500);
      const body = await res.json();
      expect(body.error).toBe("Server error");
    });
  });

  afterAll(() => {
    mock.restore();
  });
});
benOximport { describe, it, expect, mock, beforeEach } from "bun:test";

// Mock database layer
const mockExecute = mock(() => Promise.resolve());

mock.module("../src/db/index.js", () => ({
  db: {
    execute: mockExecute
  }
}));

describe("Health Route Handler", () => {
  let handleHealth;

  beforeEach(async () => {
    mockExecute.mockClear();
    const healthRoute = await import("../src/routes/health.js");
    handleHealth = healthRoute.handleHealth;
  });

  it("should return status 200 and ok when DB is healthy", async () => {
    const req = new Request("http://localhost:3000/health");
    const res = await handleHealth(req);

    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body).toEqual({ status: "ok" });
    expect(mockExecute).toHaveBeenCalled();
  });

  it("should return status 500 and error details when DB health check fails", async () => {
    mockExecute.mockImplementationOnce(() => Promise.reject(new Error("DB Connection Failed")));
    const req = new Request("http://localhost:3000/health");
    const res = await handleHealth(req);

    expect(res.status).toBe(500);
    const body = await res.json();
    expect(body.status).toBe("error");
    expect(body.error).toBe("DB Connection Failed");
  });
});
RŴximport { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { checkRateLimit, cleanupRateLimitCache } from "../src/utils/rateLimit.js";
import logger from "../src/utils/logger.js";

// Spy on logger.warn
const warnSpy = spyOn(logger, "warn");

describe("Rate Limiter", () => {
  beforeEach(() => {
    warnSpy.mockClear();
    // Set custom env variables for predictable tests
    process.env.RATE_LIMIT_WINDOW_MS = "100"; // 100ms window
    process.env.RATE_LIMIT_MAX_REQUESTS = "3"; // max 3 requests
  });

  afterEach(() => {
    delete process.env.RATE_LIMIT_WINDOW_MS;
    delete process.env.RATE_LIMIT_MAX_REQUESTS;
  });

  it("should allow requests under the limit", () => {
    const key = "user-1";
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(warnSpy).not.toHaveBeenCalled();
  });

  it("should block requests exceeding the limit and log a warning", () => {
    const key = "user-2";
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);

    // 4th request exceeds limit of 3
    expect(checkRateLimit(key)).toBe(false);
    expect(warnSpy).toHaveBeenCalled();
    const callArgs = warnSpy.mock.calls[0];
    expect(callArgs[0]).toBe("Rate limit exceeded");
    expect(callArgs[1].key).toBe(key);
  });

  it("should reset request count after the window passes", async () => {
    const key = "user-3";
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(false); // blocked

    // Wait for window to expire (100ms)
    await new Promise((resolve) => setTimeout(resolve, 110));

    // Should be allowed again
    expect(checkRateLimit(key)).toBe(true);
  });

  it("should cleanup rate limit cache of expired keys", async () => {
    const key1 = "cleanup-1";
    const key2 = "cleanup-2";

    // Populate keys
    expect(checkRateLimit(key1)).toBe(true);
    expect(checkRateLimit(key2)).toBe(true);

    // Run cleanup immediately (none should be expired yet as 100ms hasn't passed)
    cleanupRateLimitCache();

    // Verify still tracked (counts shouldn't reset, e.g., if we consume remaining limits)
    expect(checkRateLimit(key1)).toBe(true); // request 2
    expect(checkRateLimit(key1)).toBe(true); // request 3
    expect(checkRateLimit(key1)).toBe(false); // request 4 (blocked)

    // Wait for window to expire
    await new Promise((resolve) => setTimeout(resolve, 110));

    // Run cleanup
    cleanupRateLimitCache();

    // Since they were deleted from the map, they should be initialized as new records
    // If they were cleaned up, we should be able to do 3 requests again
    expect(checkRateLimit(key1)).toBe(true); // 1
    expect(checkRateLimit(key1)).toBe(true); // 2
    expect(checkRateLimit(key1)).toBe(true); // 3
    expect(checkRateLimit(key1)).toBe(false); // 4 (blocked)
  });
});
e:Hxvimport { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
import logger from "../src/utils/logger.js";
import { config } from "../src/env.js";

// Mock Telegraf and fetch
mock.module("telegraf", () => {
  return {
    Telegraf: class {
      constructor(token) {
        this.token = token;
        this.api = {
          sendPhoto: mock(() => Promise.resolve({
            message_id: 12345,
            photo: [
              { file_id: "photo_id_low", file_unique_id: "unique_id_low" },
              { file_id: "photo_id_high", file_unique_id: "unique_id_high" }
            ]
          }))
        };
      }
    }
  };
});

const infoSpy = spyOn(logger, "info");
const errorSpy = spyOn(logger, "error");

describe("Telegram API Utilities", () => {
  let forwardToStorage, getFileInfo, getBot;

  beforeEach(async () => {
    infoSpy.mockClear();
    errorSpy.mockClear();
    global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({ ok: true }))));

    // Import dynamically so mocking is applied first
    const telegramUtils = await import("../src/utils/telegram.js");
    forwardToStorage = telegramUtils.forwardToStorage;
    getFileInfo = telegramUtils.getFileInfo;
    getBot = telegramUtils.getBot;
  });

  afterEach(() => {
    delete global.fetch;
  });

  describe("getBot", () => {
    it("should return the telegraf bot instance", () => {
      const bot = getBot();
      expect(bot).toBeDefined();
      expect(bot.api).toBeDefined();
    });
  });

  describe("forwardToStorage", () => {
    it("should forward photo to storage chat and return file details", async () => {
      const chunk = Buffer.from("fake photo data");
      const fileName = "test_photo.jpg";
      const result = await forwardToStorage(chunk, fileName, false);

      expect(result).toEqual({
        telegramFileId: "photo_id_high",
        telegramFileUniqueId: "unique_id_high",
        storageMessageId: 12345
      });
      expect(infoSpy).toHaveBeenCalledWith("File forwarded to storage", {
        fileName,
        message: 12345
      });
    });

    it("should handle error when forwarding fails", async () => {
      const bot = getBot();
      bot.api.sendPhoto = mock(() => Promise.reject(new Error("Telegram send failed")));

      const chunk = Buffer.from("fake photo data");
      const fileName = "test_photo.jpg";

      await expect(forwardToStorage(chunk, fileName, false)).rejects.toThrow("Telegram send failed");
      expect(errorSpy).toHaveBeenCalledWith("Failed to forward file to storage", {
        fileName,
        error: "Telegram send failed"
      });
    });
  });

  describe("getFileInfo", () => {
    it("should fetch file details successfully", async () => {
      global.fetch = mock((url, init) => {
        if (url.endsWith("getFile")) {
          return Promise.resolve(new Response(JSON.stringify({
            ok: true,
            result: { file_id: "some_file_id" }
          })));
        } else if (url.endsWith("getInfo")) {
          return Promise.resolve(new Response(JSON.stringify({
            ok: true,
            result: {
              file_size: 98765,
              mime_type: "image/jpeg",
              file_path: "photos/file_0.jpg"
            }
          })));
        }
        return Promise.reject(new Error("Unknown URL"));
      });

      const result = await getFileInfo("some_file_id", "some_unique_id");

      expect(result).toEqual({
        file_size: 98765,
        mime_type: "image/jpeg",
        file_path: "photos/file_0.jpg"
      });
    });

    it("should handle error when getFile fails", async () => {
      global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
        ok: false,
        description: "Bad Request: file_id invalid"
      }))));

      await expect(getFileInfo("invalid_file_id", "invalid_unique_id")).rejects.toThrow("Bad Request: file_id invalid");
      expect(errorSpy).toHaveBeenCalled();
    });
  });
});
x import { describe, it, expect, mock, spyOn, beforeEach, afterEach } from "bun:test";
import logger from "../src/utils/logger.js";

// Mock db
const mockInsert = mock(() => ({
  values: mock(() => Promise.resolve())
}));

mock.module("../src/db/index.js", () => ({
  db: {
    insert: mockInsert
  },
  files: {}
}));

// Mock nanoid
mock.module("nanoid", () => ({
  nanoid: () => "mocked-nanoid-id"
}));

// Mock telegram utils
mock.module("../src/utils/telegram.js", () => ({
  forwardToStorage: mock(() => Promise.resolve({
    telegramFileId: "tg-file-id-123",
    telegramFileUniqueId: "tg-unique-id-abc",
    storageMessageId: 98765
  })),
  getBot: () => ({
    api: {
      getFile: mock(() => Promise.resolve({
        file_id: "tg-file-id-123",
        file_size: 1000,
        mime_type: "image/jpeg"
      }))
    }
  })
}));

describe("Upload Route Handler", () => {
  let handleUpload;

  beforeEach(async () => {
    mockInsert.mockClear();
    const uploadRoute = await import("../src/routes/upload.js");
    handleUpload = uploadRoute.handleUpload;
  });

  it("should reject unsupported content types with 400 status", async () => {
    const req = new Request("http://localhost:3000/api/upload", {
      method: "POST",
      headers: {
        "content-type": "text/plain"
      },
      body: "plain text data"
    });

    const res = await handleUpload(req);
    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.error).toContain("Unsupported content type");
  });

  it("should process JSON upload (base64) successfully", async () => {
    const req = new Request("http://localhost:3000/api/upload", {
      method: "POST",
      headers: {
        "content-type": "application/json"
      },
      body: JSON.stringify({
        file: Buffer.from("hello world").toString("base64"),
        fileName: "test.txt"
      })
    });

    const res = await handleUpload(req);
    expect(res.status).toBe(200);
    const body = await res.json();

    expect(body.public_id).toBe("mocked-nanoid-id");
    expect(body.telegram_file_id).toBe("tg-file-id-123");
    expect(body.telegram_file_unique_id).toBe("tg-unique-id-abc");
    expect(body.file_name).toBe("test.txt");
    expect(body.file_type).toBe("document");
  });

  it("should reject JSON upload without file key", async () => {
    const req = new Request("http://localhost:3000/api/upload", {
      method: "POST",
      headers: {
        "content-type": "application/json"
      },
      body: JSON.stringify({
        fileName: "test.txt"
      })
    });

    const res = await handleUpload(req);
    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.error).toContain("Invalid JSON");
  });

  it("should process multipart upload successfully", async () => {
    const formData = new FormData();
    const fileBlob = new Blob([Buffer.from("multipart hello")], { type: "text/plain" });
    formData.append("file", fileBlob, "test_multi.txt");

    const req = new Request("http://localhost:3000/api/upload", {
      method: "POST",
      body: formData
    });

    const res = await handleUpload(req);
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.public_id).toBe("mocked-nanoid-id");
    expect(body.file_name).toBe("test_multi.txt");
  });
});
IU*xU %beforeEach, afterAllAz2
  afterAll(() => {
    mock.restore();
  });
});
1	>x `un.lock 1%K{a58黑&index.ts {,dTώsx6ѦS=e$r9
JP)O=j;l83_100644 tsconfig.json I}rR%FS/xY `}]"&!package.json wv[j'Ol>8g]d7YId+vxW 1$kjj!jjjj/j/otstrap.test.js#
  }
}
Ox4 рTa\;=WzOj;l83_s2 x1 1$?types/bun": "latest 
  }
}
kAx  xJ @QLߛR!׳8vTIA6
Bv _#= IB(k,U&#ox   6@});
3f}x_ ++%OspyOn, beforeEach } from "bun:test";
import logger from "../src/utils/logger.jsJV});
tl!0Jx 4̯types/bun": "latest,%
bun": ["@types/bunq/#bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],

)u5`7|C(x console.log("Hello via Bun!");	-x {
  "compilerOptions": {
    // Environment setup & latest features
    "lib": ["ESNext"],
    "target": "ESNext",
    "module": "Preserve",
    "moduleDetection": "force",
    "jsx": "react-jsx",
    "allowJs": true,
    "types": ["bun"],

    // Bundler mode
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,

    // Best practices
    "strict": true,
    "skipLibCheck": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,

    // Some stricter flags (disabled by default)
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "noPropertyAccessFromIndexSignature": false
  }
}
v@Psx? W3VN_y%ss/40000 test ]Vdta=r)Gmi1x	 aAh?x -rox? W3Dy&	BC40000 test ["U*|x\Z)j-x
 "?Ah<x TK8lx? W3$Y6ͼݹ40000 test \^؈OfK}?)m}x LewbiH[za#` SxI 100644 files.js w,#Sh>x&100644 upload.js )Y )ap5!k"x T#|Ux? W3(^<!G+{hO40000 test AdO&4)bpfx Lk<^؈]äl]L` x% 100644 upload.js )Y )ap5!Mkhx T|#|L0x? W3@eU1tb.j40000 test r<O+PÊ) h,x _Eff}x 'x? W3.os\K2\:40000 test ;V1ЌH6Atē)Jwx __KpZ})9aetx pp	fx 
(/x? W3(y :kv/j40000 test 667UwkC4"Γ) sx __Kruo4R*/i#FxM 100644 logger.js gT`x]ɲU:SE100644 rateLimit.js 
ŵ_}gM4h}x zT|&x? W36J	ءd	%꺎imG40000 test Enǵ]N5#U)zx __Kze1SvߕQ{>yZ2
x% 100644 logger.js gT`x]ɲU:SEKd>x zMM@Oxo ?;5n8.DQ#W7\b'>Fϓ'03)*WdZ@YUc-40000 test H_7VݰY쌈瓊)3Lx
	sk8},
      "peerltypescript": "^5. C'typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/tB7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSwW  }
}
V%xJ -drizzle-orm": "^0.29.06,
    "nanoid": "^5.0.48
  }
}
;FxB 100644 env.js M g`'~06~>40000 utils ze1SvߕQ{>yZ2B1x' 100644 env.test.js g3S.s(D_ B%sx Xwl a*t)x  40000 utils ze1SvߕQ{>yZ2~kNx M)Hgx8 (\6sɭyg-Q{'5HRK)@x # teleuploader

To install dependencies:

```bash
bun install
```

To run:

```bash
bun run index.ts
```

This project was created using `bun init` in bun v1.3.14. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
Mmx 8{
  "name": "teleuploader",
  "module": "index.ts",
  "type": "module",
  "private": true,
  "devDependencies": {
    "@types/bun": "latest"
  },
  "peerDependencies": {
    "typescript": "^5"
  }
}
4B:x(100644 .gitignore -'NW|ݟ0ad/
[100644 CLAUDE.md vLٝi~~r(	100644 README.md 6sɭyg-Q100644 bun.lock ?;5n8.DQ#40000 docs ?6'x3H۟100644 index.ts {,dTώsx6Ѧ100644 package.json '5HRK100644 tsconfig.json I}rR%~wڦx& 40000 superpowers JJ5&(zx  40000 specs I*9 йt菿ix	 uz_;fUZ
>