# v2 git bundle
06f93e30f633d8f244d0be06665ae81217baeadd refs/heads/bundle
06f93e30f633d8f244d0be06665ae81217baeadd HEAD

PACK     ɑ7xqtree d2967ae6c1acb9d81da923a28496e930a767c1fa
parent f88ac1a124eaf0cf8ad0dc488672a282bde8ace7
author asepharyana <superaseph@gmail.com> 1783367691 +0700
committer asepharyana <superaseph@gmail.com> 1783367691 +0700

feat: proxy GetObject from Telegram CDN for real S3 client compatibility

- Add proxyS3Get config (PROXY_S3_GET env, default true) to env.ts
- Proxy handleGetObject and handleGetMultipartObject: fetch from Telegram
  CDN and return 200 with streaming body instead of 302 redirect
- Real S3 clients (AWS SDK v3) expect 200+body on GetObject, not redirect
- Legacy 302 redirect path preserved when proxyS3Get=false
- Updated production-e2e: GetObject asserts 200 with body content
- Updated s3-sdk.test.ts: GetObject asserts 200 with body (removed try/catch)
- Cleaned up unused multipart imports in s3-sdk.test.ts

All 49 tests pass (29 production-e2e + 20 s3-sdk).
%ې8xtree 782cd369b90c96353e35f3bca8bb50ee12691c64
parent 57f2740df967ba7e36faa525b3d74aef4c0e0817
author asepharyana <superaseph@gmail.com> 1783364357 +0700
committer asepharyana <superaseph@gmail.com> 1783364357 +0700

test: add real AWS SDK v3 S3 compatibility tests (20 tests)

Tests use @aws-sdk/client-s3 against https://upload.asepharyana.my.id
with forcePathStyle=true. Covers all standard S3 operations:
ListBuckets, CreateBucket, HeadBucket, DeleteBucket, PutObject,
GetObject, HeadObject, ListObjectsV1/V2 (prefix, delimiter),
CopyObject, DeleteObject, DeleteObjects (batch), error handling.

Multipart is excluded from SDK tests — the SDK adds amz-sdk-* /
x-amz-user-agent to signed headers, which can differ between
signing time and the actual request through Cloudflare, causing
403 SignatureDoesNotMatch. Multipart is verified via manual
SigV4 signing in test/production-e2e.test.ts.
t2~;xFtree 84855ac5479687c8c0778706ac075d05e4f788ca
parent b731ddd7e1e5f950ff9b2b96c32ec57a78ee7ca4
author asepharyana <superaseph@gmail.com> 1783360185 +0700
committer asepharyana <superaseph@gmail.com> 1783360185 +0700

fix: production bugs + comprehensive production e2e tests

Fixes:
- RowList bug: postgres.js returns array directly, not {rows}. Fix in
  buckets.ts, files-ext.ts, multipart.ts (3 files, 8 functions)
- S3 ListBuckets routing: GET / was intercepted by handleHome route
- Presigned URL detection: isS3Request() only checked Authorization header
- FK constraint on bucket delete: cascade-delete files & multipart rows first
- docker-compose.yml: pass S3_ACCESS_KEY / S3_SECRET_KEY to container
- Dockerfile: copy home.html to runner stage for handleHome

Tests:
- test/production-e2e.test.ts: 29 tests (11 Web API + 18 S3 SigV4)
  All pass against https://upload.asepharyana.my.id
- Creates and cleans up real buckets/objects on production
YAxtree 9577dbadb4df24f07d1e90c599b854956855eec3
parent 245138e23a67720380ef2d58d8654c7acc18502f
author asepharyana <superaseph@gmail.com> 1783353059 +0700
committer asepharyana <superaseph@gmail.com> 1783353059 +0700

fix: sequential test execution to avoid mock pollution between files
0[L%xQtree b08cc1ac8b0e16e7dcc71abd0e17c56e868da78c
parent 2d3d7891a0f00868e220e941b6b8e335bc78a59c
author asepharyana <superaseph@gmail.com> 1783352175 +0700
committer asepharyana <superaseph@gmail.com> 1783352175 +0700

Refactor code for improved readability and consistency

- Updated formatting in web-api.ts for better alignment and readability.
- Enhanced XML builders in xml.ts for clearer structure and maintainability.
- Improved test cases in s3-auth.test.ts and s3-operations.test.ts for better clarity and consistency.
- Refactored mock data in web-api.test.ts for improved readability.
Qx&tree 41b7a36cfe9477c2c79db5b0baec1dcb5b163c86
parent 0ad783b79480ee80e3170fb28e35b0e400c6e6cc
author asepharyana <superaseph@gmail.com> 1783351926 +0700
committer asepharyana <superaseph@gmail.com> 1783351926 +0700

refactor: auto-run DB migration at startup, robust schema.sql path resolution
_x0tree 5b53efb9170ade30aff42c221836e4eac896dfac
parent e175a275129be6ee26ceec51ab5b43a13b2f59f5
author asepharyana <superaseph@gmail.com> 1783348697 +0700
committer asepharyana <superaseph@gmail.com> 1783348697 +0700

fix: PutObject raw body, presigned URL auth guard, DeleteObjects tracking, home.ts path
bgx	tree 148bef42f2fb9f67b76bb9278e6edcb10cdda798
parent 5e38f80267f838a33ef98fc1f3beb7b9a4e0c242
author asepharyana <superaseph@gmail.com> 1783347326 +0700
committer asepharyana <superaseph@gmail.com> 1783347326 +0700

test: add S3 auth, operations, and web API tests
8Sxtree ae6d20f54ccc818b65e18f908f80f602ca8ad173
parent a4f902a37b77eef4376546a880c66cf1fa0578d0
author asepharyana <superaseph@gmail.com> 1783346215 +0700
committer asepharyana <superaseph@gmail.com> 1783346215 +0700

feat: add web file manager UI and S3 catch-all route
xS˗xtree 6eb4478f8887bd4e2d908dff5c5f9ea86d46cde7
parent dd61e7e0fc3952d2ea610c014d6899c11a829808
author asepharyana <superaseph@gmail.com> 1783329017 +0700
committer asepharyana <superaseph@gmail.com> 1783329017 +0700

feat: add JSON v1 web API for S3 management UI
Px6tree 3a2ab7ef72b1ce9f523324e2c331c74fe1688606
parent 084ff3fb9dfce2b2af1aa2180410897adbb5df9a
author asepharyana <superaseph@gmail.com> 1783327962 +0700
committer asepharyana <superaseph@gmail.com> 1783327962 +0700

feat: implement S3 protocol dispatcher with bucket, object, listing, and multipart operations
"e`xGtree 8176c6ad5841cb67eb75d2e0edc2807a74fc6422
parent ee758aa939ca32954b5d581784b67b33847c10db
author asepharyana <superaseph@gmail.com> 1783326666 +0700
committer asepharyana <superaseph@gmail.com> 1783326666 +0700

fix: add region validation, fix presigned URL canonical request (add method, fix headers), normalizeUri decode
4ixtree aee21a516531b02faefa52d54f6ac9fa8b3f492c
parent a2ca4890395e36f1482b4cb4e783a4274d3177c6
author asepharyana <superaseph@gmail.com> 1783324599 +0700
committer asepharyana <superaseph@gmail.com> 1783324599 +0700

feat: add S3 SigV4 auth verification and XML builders
Tx@tree 6529aed94e761b73682eb20d1db856de2189fe8f
parent caef31c7039d55c431340344ce2efdf44407a475
author asepharyana <superaseph@gmail.com> 1783323207 +0700
committer asepharyana <superaseph@gmail.com> 1783323207 +0700

fix: add explicit snake_case-to-camelCase mapping for S3FileRecord, add missing findOrphanFilesByBucket
@]gxtree d57a456e9fecef18f63a6c104ca24150bd57640b
parent e37fb00295ce61b167ad947e84684403fc53e463
author asepharyana <superaseph@gmail.com> 1783322617 +0700
committer asepharyana <superaseph@gmail.com> 1783322617 +0700

feat: add DB CRUD layer for buckets, multipart, and S3 file extensions
:Yxtree 2d0b1befd0d175379a370d1cfd2574dc228dfa6f
parent 0d0d7a5dfab54cdd8e0f3ebd3ef1e12ba80e940d
author asepharyana <superaseph@gmail.com> 1783321143 +0700
committer asepharyana <superaseph@gmail.com> 1783321143 +0700

feat: add S3 env config, bucket and multipart DB schema
VĞxtree b3b7a0cb1de20c6be7867550e5f03a10d1d3138c
parent 52bd704d21ba4f98c14b2866dcf5a541673c5aa8
author asepharyana <superaseph@gmail.com> 1783275937 +0700
committer asepharyana <superaseph@gmail.com> 1783275937 +0700

chore: remove GitHub Actions workflow for notifying parent repository
I\x/tree 0ba5ec7d813cc04427bf5fd6147f18b40e68fe3b
parent bbf420759b5ab1e3d0542cad582d71061535f7b4
author asepharyana <superaseph@gmail.com> 1783275918 +0700
committer asepharyana <superaseph@gmail.com> 1783275918 +0700

refactor: enhance file handling and metrics tracking; remove unused bot health tracker
	NbLx7tree ea46106775c0d829717e4b7d290c55d750f2dcc7
parent 20b622c4c2f063750fe24025c096d93a5564cf62
author asepharyana <superaseph@gmail.com> 1783264012 +0700
committer asepharyana <superaseph@gmail.com> 1783264012 +0700

chore: update Dockerfile and docker-compose for improved deployment process; add deploy script
d($xGtree 50836317caeff4cbbdf33a22ee664d5b25a5ba0b
parent 50176e57643789119a65e676bff1e0f87380bb0e
author MythEclipse <asepharyanalm001@gmail.com> 1781968910 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1781969063 +0700

fix(core): resolve wrong file_id error and zip stream memory leaks

- Refactored Telegram bot pool iteration to handle 'wrong file_id' properly by verifying against all active bots
- Upgraded TelegramFileInfo interface to track owner bot_token
- Patched MaxListenersExceededWarning memory leak in zip.ts stream writes with events.once and async iteration
KxYtree 5f380722e9e8946983bb3632685b1ca1f85e9941
parent 2a833d3908837f566462adacddbf144de1cfe576
author MythEclipse <asepharyanalm001@gmail.com> 1780724366 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1780724366 +0700

chore: migrate domain asepharyana.tech to asepharyana.my.id

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cnx#tree d75b0711d31f1e74391c8844b1585c795ef43102
parent 3ca5ab798337709088caea811611d3ad045a84de
author MythEclipse <asepharyanalm001@gmail.com> 1780074935 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1780074935 +0700

feat: include bot token in file info response for Telegram API
Z?xKtree 3325ba98b39acdd21bb6681262fedb89d3bb9a6a
parent cc0f1dde7949c12c45c6ccb5999a9be330dec98e
author MythEclipse <asepharyanalm001@gmail.com> 1780048435 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1780048435 +0700

refactor: update Telegram file handling to use new getFileInfo function and include bot token in cache
kxYtree 78d9e919fbfc54e362f63e56be99ba94d54bfd8e
parent e88e5dbc392154c49abcff683c62d96c13f0fbdc
author Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> 1780041600 +0700
committer Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> 1780041600 +0700

ci: remove obsolete deploy.yml, replaced by notify-parent.ymloxctree b439dcdff2e7b191f925d41eaa77b8f55b599cf5
parent 0e2f09012f2bb79021edb301924de8769cd51469
author Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> 1780038530 +0700
committer Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> 1780038530 +0700

ci: slim deploy.yml to test-only; build/push/deploy handled by monorepo/r.xStree 41f17987446a2336b626cd9040fa9df827c99abc
parent 134a7d4bf6623ed4bb2c5cce42224c064eddb269
author Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> 1780038501 +0700
committer Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> 1780038501 +0700

ci: add notify-parent workflow for monorepo integration`0lxNtree 0cd4b387f5537b1635ef431311ec88947c890c1b
parent 8508b360268f649e19763d1fb8b02f95b2fbe593
author MythEclipse <asepharyanalm001@gmail.com> 1780001157 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1780001157 +0700

feat: update Traefik router rules to support additional domains and change TLS certresolver to Cloudflare
'k>x<tree ac3ed7af1266d9e266206b434af38d3adef03aa7
parent 5425f6d33ddc7d85095e40ca3922189a26893ba6
author MythEclipse <asepharyanalm001@gmail.com> 1780001029 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1780001029 +0700

feat: update file handling to redirect to Telegram CDN and adjust Swagger documentation
6XdAxtree 238174bcfd5b14b21326dc41f8a1bc43b4ff05e4
parent be813b1c0ed57ffa0cd2b817f52111a736ed474c
author MythEclipse <asepharyanalm001@gmail.com> 1780000419 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1780000419 +0700

feat: enhance configuration and rate limiting

- Added new configuration options: trustProxy, uploadConcurrency, batchMaxItems, batchMaxSizeBytes, and maxRequestBodyBytes to AppConfig.
- Implemented utility functions for parsing environment variables and masking sensitive data.
- Updated rate limiting logic to use configurable window size and maximum requests per window.
- Introduced a middleware for rate limiting on specific routes.
- Refactored file handling routes to support streaming downloads instead of redirects.
- Improved error handling and response formatting in file routes.
- Added support for oversized request rejection based on Content-Length header.
- Updated Swagger documentation to reflect changes in API behavior and responses.
- Enhanced tests to cover new features and ensure proper functionality.
Wux/tree eb2e4f5ae7b4a602a9144f420c5b994bd90d26a3
parent db5de1367d10b0cfe302307583f978f4e7f26004
author MythEclipse <asepharyanalm001@gmail.com> 1779998270 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779998270 +0700

feat: update Traefik router rule to use new domain for file upload service
'<`Gx_tree 870f0ecb920364bbe6c1511897218c87fffd8d7f
parent 56b929ffb1a7cccce64fa6cde1f8fa808a4a084e
author MythEclipse <asepharyanalm001@gmail.com> 1779385909 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779385909 +0700

feat: extend file schema with archive metadata, implement batch upload processing, and add zip utilities for file handling
usxStree df8f3dc68c75626ac4ca4f8733add8369318fafe
parent fd5eb98586471e609a95402cf692bfd9f60c9235
author MythEclipse <asepharyanalm001@gmail.com> 1779384594 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779384594 +0700

feat: enhance file upload process with temporary file handling, improved error management, and metrics logging
jnśxKtree e80f5aca7db2726df26b0d123f0ef07b757aafcc
parent 10c968cf0105ed08e1a4412c3f142ac455ce3036
author MythEclipse <asepharyanalm001@gmail.com> 1779381109 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779381109 +0700

feat: implement caching for file info, add rate limiting, and enhance upload handling with retry logic
rjĜx\tree 7e46ef0b3eed0d72890844e9974c87f7b077b4c0
parent 52340ee77db8ba46d74ebfb07b2e780845f21562
author MythEclipse <asepharyanalm001@gmail.com> 1779378942 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779378942 +0700

feat: add file_hash column and related index to files table; refactor Telegram API utilities for improved file handling
`	p{xJtree 196111f2329d8bc2a06fd00a354bec88d3b8281e
parent 06844f1fa1a7fe8ba4f54fabc11568eab5812585
author MythEclipse <asepharyanalm001@gmail.com> 1779377515 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779377515 +0700

feat: enhance file upload handling with improved file detection, size limits, and response formatting
jיx9tree 12cc495bebb3300cb0faa4045d81152d5c5e4f7b
parent 884710fd4244224b457994c4a99d1ac8d2bace4f
author MythEclipse <asepharyanalm001@gmail.com> 1779116635 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779116635 +0700

docs: update CLAUDE.md to include details on temporary file handling for API uploads
c8xEtree fd0ef86635ba8f7a6beb75d82254954d3d48a530
parent cc3f85284bdbf2e52f3e9dae6e3ffac3f17737c1
author MythEclipse <asepharyanalm001@gmail.com> 1779116486 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779116486 +0700

feat: implement temporary file handling for uploads and update tests for dynamic ID verification
'j x)tree e8326c34eab734e18713aca125193a30a1ad623a
parent d58b2390efbce1b1b9e2778c0a6a926d6482f84d
author MythEclipse <asepharyanalm001@gmail.com> 1779116097 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779116097 +0700

docs: add dynamic image fetching details for unit tests in CLAUDE.md
\].xCtree 3644f9a34a5457778c3db5200636be15a1fe80c6
parent b91b276214729c7cd46cdf79ecaadd89032d4869
author MythEclipse <asepharyanalm001@gmail.com> 1779116079 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779116079 +0700

refactor: remove concurrency limit from Telegram upload queue and enhance media group handling
hx;tree 8d3360c90815ffd41372b1bf9565554b62586b0c
parent 0bc12651db86c9a33f01caeb3263540ee4295c38
author MythEclipse <asepharyanalm001@gmail.com> 1779114680 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779114680 +0700

feat: add support for batch uploads of media groups to Telegram with debounce handling
ɥcx>tree c6c031f2c1522f0d4c4e1a748aae55cbeea212bf
parent 825e9695698d6c406fda99caea74099cc36851ed
author MythEclipse <asepharyanalm001@gmail.com> 1779113978 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779113978 +0700

fix: clean up logger info message formatting and remove unused import in rate limit tests
gfxOtree fe4b94f9ecfc7b2003fd32faf0a2664807481b9b
parent 721fa3db7d80cf49e75bee8ede46240346eeee95
author MythEclipse <asepharyanalm001@gmail.com> 1779113896 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779113896 +0700

feat: implement multi-bot support for Telegram API with rate limit handling and add task queue for uploads
Km!x?tree b4bc79f9bc5b7c4f2df20459f3f9f500802470c8
parent 91cfee0b375b2ed93cb76db7ab0e17207a288325
author MythEclipse <asepharyanalm001@gmail.com> 1779112958 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779112958 +0700

feat: implement auto-retry for Telegram API requests on error 429 and update documentation
fB#x1tree 545ab9856e7e0f488d8df2309536310cc9dca63a
parent 525348b1f3c24148a79bd85154a65be52df91ce5
author MythEclipse <asepharyanalm001@gmail.com> 1779108903 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779108903 +0700

fix: load BOT_TOKEN from process.env in tests to prevent leaks

- Update test/bot.test.ts to use process.env.BOT_TOKEN || 'fallback'
- Update docs/superpowers/plans/2026-05-18-deploy-plan.md to use placeholders
- Completely eliminates hardcoded active API token from codebase

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Q;v2x,tree 885f872069069d51824b17ebd5c145a300e29121
parent bf6d5f04c4e6243ed679852e2a23d246dbca9fe9
author MythEclipse <asepharyanalm001@gmail.com> 1779108766 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779108766 +0700

fix: make forwardToStorage fully dynamic based on fileType

- Replace forceDocument boolean with fileType string in forwardToStorage
- Dynamically call matching sendPhoto, sendAudio, sendVideo, sendVoice, sendAnimation, sendSticker, or sendDocument Telegraf API method
- Correctly extract uploaded file details based on what Telegram returned
- Fixes IMAGE_PROCESS_FAILED 500 error when uploading ogg audio, video, sticker, or voice notes via HTTP
- Update upload.ts, bot.ts, and test suites to match the new dynamic signature

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
(x{tree d363c40e1da7a98b7f105f2a2fff32fa5f570c9f
parent b2de5f583dc33c5b523188dac54f75e5acd95507
author MythEclipse <asepharyanalm001@gmail.com> 1779108381 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779108381 +0700

fix: bundle migration script and ensure file_hash column exists

- Compile src/db/migrate.ts to dist/migrate.js during build
- Copy dist/migrate.js to Stage 2 (runner) inside Dockerfile
- Run bun dist/migrate.js on db:migrate command
- Modify schema.sql to use CREATE TABLE IF NOT EXISTS and ALTER TABLE to add file_hash to existing database instances on VPS

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
$nx3tree 4314d81b6954b986b9988d59744b6f133720ee8d
parent a0262e2d46e86ea750467229de02c8f8e759b4dc
author MythEclipse <asepharyanalm001@gmail.com> 1779108137 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779108137 +0700

feat: add design document for multi-format upload and anti-duplication feature
"ahxtree 705ddb7935ee847e253bf74a3dbe699246be270a
parent ea4341fdfe443845d9c2ba17b69b272db75ead25
author MythEclipse <asepharyanalm001@gmail.com> 1779108111 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779108111 +0700

style: run formatter on modified files
Sߑ%xQtree f739b5a8cd348325205f4f284df1f9d7ad1d989a
parent 04a2e52250658ffc17499766546c4ff0880f2d6b
author MythEclipse <asepharyanalm001@gmail.com> 1779108076 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779108076 +0700

feat: implement deduplication and all media types in bot listener

- Expand registered bot listener to support sticker and video_note media types
- Check database for existing telegramFileUniqueId to bypass forwarding duplicates
- Update tests to assert deduplication and new formats handling in bot.test.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Y'xtree f4a6c617232e946dba90f4339a5b369b4b309f48
parent 4e805689c14fd3702b0c9f1f38c03d242f5ccc61
author MythEclipse <asepharyanalm001@gmail.com> 1779107995 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779107995 +0700

feat: implement deduplication in HTTP upload router

- Compute SHA-256 hash using computeHash for multipart and JSON uploads
- Query database for existing file records with the same hash
- Return existing metadata and download URL immediately on duplicate match without reforwarding to Telegram
- Include fileHash in newly inserted file database records

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Jׄ&xctree 49b33aab22778d2fd3dc4223b5709affd32ec8f4
parent 22c2133b24d47fc9d4e3fad1925cf8bf67067e37
author MythEclipse <asepharyanalm001@gmail.com> 1779107892 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779107892 +0700

feat: extend file type classification and implement computeHash

- Add sticker (webp) and video_note to classification mapping and file limits
- Handle unknown mime or caption fallback to document
- Implement computeHash utility using Bun.CryptoHasher for sha256 checksums
- Update and add comprehensive tests in file.test.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Gxmtree 732058b5a4cbbb1715e08a053d8aa03d4ab089d0
parent 3d2500ed7f8a83a11325f6ae84eb57338896125c
author MythEclipse <asepharyanalm001@gmail.com> 1779107779 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779107779 +0700

feat: add fileHash column to files schema

Update files database schema and test suite to support fileHash property.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
B+x1tree ab661af0d1b5d30035be5d3dbb6664a594637c9b
parent 5de424bf8e90a23fc8544b9a5f12f6c4a49b9495
author MythEclipse <asepharyanalm001@gmail.com> 1779070803 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779070803 +0700

fix: auto-append file extension based on magic bytes or mime type if missing
8)a>xtree 2116562f406a0c84b702c26ae35a7445e009d07a
parent ed7f607ee4c5409c19292bc2e627732ed99f7386
author MythEclipse <asepharyanalm001@gmail.com> 1779069382 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779069382 +0700

ci: add write permission for packages to GHCR pushing
;W@x tree 786a33112e62f191c3e74cdca56e19e08bb5f4c3
parent ed88069e60f7f2f352b1c66264f084a1e77bcd21
author MythEclipse <asepharyanalm001@gmail.com> 1779069322 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779069322 +0700

style: sort imports in telegram test to satisfy Biome check
s[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$2pqTyěK<֔ԥg@Mc-`-um}?.I@U9804_}~}+ˌF_g}N*r;--hGIz7ɬ~.O. WG_QUɬ?~@eoVo#_2sS^~'r¤I<ܐ{anO*Zʰm/ۺiL>w@5榦
)9zqvss\Y
M@M-/NիaNyۺb=f_=0D3Kڰ1R$eU8jyaىP׋,D޿AcW~1	89#57Q0!han9OH5[>bYqQ2a۽ғ:{wE!%%}.}4k?bV'eCܲژrLa9iH_# x. node_modules
.git
.github
docs
logs
dist
.env
4%x]Qo0+,evRⱨ!i'$bvgs<+gci%wzoZqp,-rمD-N(>4Lʊ/yN2&q8xrs{{0%|]fq\g@XaB8u`ngi`MG+6^LJZ-j0(Y<(ȋp?ŝu_9U'RmuPbFx.SƟ<)w/$kBb2*߱3= t֪҇(PhT52
ޏ3rT_VTicrjɝP}[tN8iU*'h|N(ķ"A3Bɼ$9ބ|p0p3]6a]8ĈxmPMk0Wzi3:(;8Fpl%P`+9ONalϝ0gP~X#WDǒI^s;/΋:;\H&J&i
%N~1qVdn3}0pf)>
,jE=,7`k,?btkLȰD&E.bRӶRL׭l%d<y;@<JӞY_+d>=["f/1u?
57	8pNJxmW[sۺ~ׯʝZHI{P3Iso츱:y!` .N<c^{-с+W!e|$[ɽzc/p?[*-S܀paON>0ۃF2əCO߅OLE-M'}d?B'9T©:<8o>>Q{%Vf*aʟR8	A0OڊDyK5x
8D/.ϩ?=0'ԵuWF?݈m	>v|tNK8~Ox>_'%6RϘMj'[=^a.[փ[cexɥtI?]ғCv*:O)?ڟOd!X$iU!OT	jyņ>1Y{ʽ+&(3]J.nf%ЊE52rDU@"ZN'pOH̭kaE쇑vpPNג*t5^Öq'tkj8$S`}7/?}{ym_onrl`3:a`i/J!`!SXw_*O+	6'v$JLI,H#rBp7,1ِRZPB2<Tr=
@TrqZfM0M"k6̄	N	^% y+K2@m(BU|%<*/:3O
P࿋ٗc[D鿲Rm֢n^BX<*vCH50z'3ε/hmZ"jC
GqZVxe۬okeJp9-:!B0DJuߩǥ)^E4~Vmr~b[ј	_	#ᵢ
}yG	fع"0=f)CueĨX`BC/x==Ǵx׍C<Կ@䮳*#2Be葖VI?z=RkK[t`H/GZ$W1o5[eJ'J=mUXNNXOv̡m.Cx{{$K{-Y%Q?O}`TݛN~?(g7X/	jyRr
B'!$<&+~dqYbivpYgE߁q[fmS9ɣ?[
k=~);c/g`DD8:J u+7%TDjy<z"px{*P1m݈QxwKR*\'_]8պN_ڋmBL+*2qak}D>p{#zeD^ٛ-lϯ26pҢJa_ʖ}4/ۚXfYԲ,I.+iXK,lе#+qWoJzsrh>z"͸UVhsJgůRlU~ٕ6/!PPD렴E@lײ1ء搊)@Xb9Ҍ;t(xS;'pyi`{fY{*'L <HҺ-eg;f<g&U.+xAO@+&	7
%%FSX-l ݙ}o}n*!346wu\B!\`cM	D55i˒̕u\JȰDJZgJea6M3<}U78jMs
#<mEz7LBWuKR$=i6=>J7X.ƐTJ}b(<4ER=BMX!pu7	ru.ʄu {PBlwxbU¿p
I-?"!KmJm=-.GQ8hvtK@!+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<".;ɭ:݆9a170[ >Oҏ46@̡铝\Jdۓ'^Q!?÷o?wIQGo<?>!Qus4]'Z#Q90@ͽΆhw)h<`hμ23쇽bgC=eg؂kְ¼V,4WCQ_*YuN&0][ږkq|Ԑ5s0#轣wj.NNǰdoнF! m[b|zg1KZVir)
(4hZ(upKr]?_52QxKtclf `1WWHpel2dmA?c7ym @eXh1	+q-P~!JScxjg'΀"ҥ7ْB{0mǍz >sF`>gto
r,&\Dt0㢫p5.1x~֤EdO)5l;u[qpG<'Q1!iё.[)趏YG%95dE؀IC`)hcwy4t2gx10ƹL.ʐ?cb0Ø>*2W0K>f1̸Poe֊BҌf2tEFK̀KqvXVPP5@DLfqݜ_ڀkEhSjulk:Xoؐ[U:AFCՅ

# "xLx")~58	lW\q-t3\Y}TS0:T?5Qdv@ɦo8w"PX;z/I$>2^l[UZLS5DG .K5"@K]c'C5
<JAqN	%BXL QBZQq~C/@.5bßͶpʚjxkPp٥6hCc^ex(3(, pڃ(D0Y<K}˚٢ D
}ްÕM4( B󤀔 RʥZ<]`ԫFû AP[)e\sGɭGP:1ʧbIv
jC FUJ<e 6<Wdx}4rcUMֺ@*	nՋ*/yr=bȲа(E3C5fK+50p^RT'`,c(6YG-yStKC~dƖ!%-=.n6	}Ā{r47.ZA*a7[X摠Se:Л>h$4qDפX6S}5[qnIrq)DSL7o]su4ϒ^"k_*
'	"oTV4VYRÍͮʥҀ(*hH#cH"VsP%4QHl۶!Ė/t9Ia3;m	dʈt.M]n{#^B;tn+:Re,(;6WFpȌ(+,spzgk-"֌s .)aMhL04vYg
:ᙷ]aI(jSGVw-X{jُ:,ҷ6"'0g$P5'h'٠5pA0x4Ns[R{&
*sj}1B2\.&'WtsU&!aq1< 
N+|$=|c'M\0j%rI{btH	6Rqt]', |e-2ĒL*#vBl>%#i"}]#JTjkX^lt(P˟ V9s@VʷUZL{GCj]R9vM
f,ZP۽,N	t(8aq
P8y}pDE12r'u۵v0Jhꭧi 4Uv󙴛'(.|G̉\טPF7Uqb/4i; i#x	EUzmks0NЮv0<a>>3`ϝ{JcyKLkƃp_ah쫆"OX%.Лkq[l)joMM+Jxu?Xg/G"[krQv!I 9Qw3<6'r$q> 2	م"Dy}E0p+!w@(Q.bHO0,M"ձ8{b%72V~Y?;^s!9X,-`!rݻeXPg*\*M2gЇݲːu[}ES ||=<`$ n4]]fimy<"۽#]/ڠ)g׫jJyce9!ߺ^eE)SKqA)K;cJ0Vl}xig!9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',fVUvt]QJUegnj7КX閑<XVG"ҡ0O}j+[<]Z,Xz:89;:!]}}[,Jf]~Ҫ;.hsJЦކSfϗe[e;+GDdO}-`;F~/>ތAq0D^{ c#tӤw؈\vͩkrIxs=:-P)>)6NdBHWdl,]~Rl:g8oځ~M8<?i_6BNUǷӨ+:pS>v%jE @?X\+eJ9fi`%?k!D eG:*qȜTN* 3HQc4sKPЯd؋B'NϸF?5(
˽~=f[=d8&{g=ޖq+|㖅`ǊyX㐢GV1kz)CR9}@#@&VI&~VUvc'״U]0|WO_߯ȮOprɭ^ίs}?[ሯ'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{q{
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|nvO-bªBhR´J<ku+u)ƃI2Ὦ?jkVYĉJ5e+$a>y_g,ގ8cm>,/GW݋rWcz!\%xg=LI9gh=÷ͬmȵr	QK;~-תo\P:iG$h3ec6W;)0<lFPiH8 ZaH	Y%eh!Ʒ%nMo"
FW"[XϢ1⍖]O ndz#K6Yci>= 1?1<f>F!&gVިy6zIb>c|=)n{*	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r7l+ctd{ғh/+.eZgaݕ*M%RRYpʶkj@*F7f}HrԹ92BI2.`Y/=7F&ۊzWqsu5lͯoQk7: G|
Fz qO6_sPj'^ʛ/<Q~/.:<Q{`o3m}
WaMEfc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pfZjC2qX|+o75ejzDm,x
= =J[| q\L?,]~˧ssCM=芟Gt{R}%68<XI%mWQ˜x]8o(7A~CIi F(wU7(6M-5$SVvDnQW~_dL"s(L,AyQqm~1@2o5ɿ_|LCsUc|'Ɛ>0wFi#dL=Tf#eFW1xER:йpa:EPt3^f>YlUǈhUx-`W p|wtgVߊАQ7#OY!Wsx|W瀇.U*<C΂nzZZZO
7~WZ'}vFFn|c6[G_VE*8Y+R!d*NE 2HL6rUR&wc3ԑ,F}5-x{ށx*4	w{^{kU_kUuz%{qWĻp>9LГX*ċi?x	9>Odg߼[B[zK.fSup@>MLMgoE[U/! _H~鈌>*-Ʃm߃
Gwy7%ߪɶr@b5UH'{zc_Bw^=:DB+/X/]p_ְ4Oz;
y`@|<WY
B/%3IWϺd$_t d/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wDN:GPLTS=QAk(coIͮKzby	QȩDCE;o񒔾eoZ6n4:dS/tśTnWƇPt3ov2QYɵO4ijуl\=čƩzV=2GE]ers}׿V{:4£H>)O	VyRӘ}ژ:o)?#I1%ger0FA^n6oҰ9ت$^ʇZI;.LS^ꋷŵq[=l#ioCK[u%q-xƉ7ԇ&'X S;*cq^!vwz:_uVQGJLGH=Xz
O1ywt8rAǏa=ALY]4gEe|)՘xٵ7xݡzb_HL z<HVP

.ȿǌ"Uzl$tG	s)a9Ǐfl[x #'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ܽג뺒 >_qb?Zyczh$z"e(ULߠI{1WeRժOORE"P: Dn|Wug?T1
nKv\os9SG'~[$z~gϐ,g:I?~)U %Ffgy=g%Q~LݿR?몋๜	7n"'>T(g=}{UJ}.^Ig͏}Q?Dyj3+f,rLqԼ
ϿvSn/Y-cq6.-Wu>?<1
A?᳖φG#.WP3'2{L;}\Q"ϒ᳊'XS'*dlgnG80%;KCLEگH@j)#۩d`0lf0 ͋#r1TL"R\Ac˿nExaw~w$x3{7V&EwѹnnO_u]_y^v?2'TB a?6iDM(߸Ᏸi)^'u_=1Q\y#"j|V&۽x%Nna9\0bU; 8N- L}Jaϖbh0sơnR5OF*ة?%4o^o>޵NNׯT!t3v@,n "omew'J5'j̈ȏ ˄Qx')w/	qk3h}iP~ݨ>k+Bkuh[|(-n_H	`YbB(SWx[L7`&xAȰi.xp@e1^OXowTp5v>9C%tA78N쩼YjuݥaMar
bZnVWEUY7G?=ʽ4v/_~B[eu_}G\ڌy⛼DÚ]ͧޛ9c?-znu4'
ivvBU=e'@XGN&^nyjUL{(}i1KzmKSolR۟ׄc&54/&ɓRtpFrkȕ:,($1)Yy[љqXmU&"^=(	9KCzk{0]3[袘_[:{Agy,<1)N lԟlnܧ,悝4ƨ./L_:( *)5-qk9D~nz&s)u]niRW4mS,cl>9e	RJ{r<bԿgR;ψрBrFvrtLغt
mN޶+ՐR XӪy5_Q7씙gi*6Onzߨ65
ڦšϰܩj'JG41}xFb/ gQ@ȕf}/&f17F&O\=_$OH?3@0jK(ܑDM;A!pGpgBG@=Riڎ}	>~{4BOcj)bJ W	5 XTr6
Lga%8OOU󻭷o2qJ@{_C1̻eƀ-qw(B9QGE-hWqY:pQZJL"S/Vч{X)!)9DJu?q|'c׉RAT}r&v8 mÌFpTyNRWG5&"[w`fc7o.TCqddlASmRMuKԚڮFwZa|PmeQtj7 $iEa~?<\']*nLG$pE૭܎NisqjAx	9P)&rG}'O'/I1R e-vKhsvabQ'>
X*+rDD,gaG{-f,t13XH}5Ǜ%7}Es/vsUe?*%;_CDJ~[ֹkߢ~GWvE~KO>Usɫ:oCP2ʩNtXCvAvX0Fu@hVA"kI%$c	rS!aߵ;|FS[O/U]T-P]/ss=&{p׭,S_VN]ۉlgH1!%D'5U;/p˂WTǥY(4,\v%s{T͆T>,*4j7=L9d,rcx	;_*'mZ=pS*+K6-+ChU"P(y)mjIpi6",D.W \9wL=ce;LِBErY3hm ju8(ϫ١d$bmM{tl)<dM:{NSwM^nm:fkahJFNwfbB-ɋadw(=dϺ`ontCQ(qr_x>NGo449.Kڡ7AdyC" 8lB7hTՠ.?U)}%^Z1ʉ~~	7_6^ \ҽ]ρVpZPdW;%%)!ήFXyFR' -]k{}[LLŗx:}_dy3lT_zX8Em*+m3`	̬xBw S7H6iBpn<ɫ3^8*z~=_I Z.
aԘ[LCZ\KzێUےO=!f,#fIz~Zzwy^dY^7JmYϯuW	=V癷v{m%n,t+L56kՉf$x;΂Q0WKӼ++0MݹuDftT)6q&r$Z1oiqoSg{z#O䎖&Y{ie]2Yn,8z8p\t'FQRyX7(1[enݥxj&ۂ_?O&g?&J^uuvu?\zI~<`HGE^e?FEnYOYY;o,<:P/襣ON6̕@3yK ٽc.Wr=<͒Z~*Χ)/	]sU%9˽5q~H恾hEH:>OCu\*uZWfs|[nJ+hGÏpߺgDʗppɂGbvU_zA%]Z9nج΃H`	)N0h̘rp8-OXȼ*熉_OxAJ@-wK3ֵ]bC{&ӶN6藜m` ^dY-5aƵlr{XQc>.A4*l9 ԉ6۵mڏ DYj66ІQf-ݒ1>cf3q $9apo H|ڔA"YN&m]q7{؋䩕Fd|ӻ{XnN	1qՍC017cuRN S0,b1kOwNuncKI/y9wՈb.ofȒFg)Gzuz~ɅUyq2eiY-,F}ǘULǹXonK}+җJ	hf8̤·P6.Z\	@׾o<&[B0:عLQf17ɿ7?h}Vn!=244v-f!@gA󙎏ǙkH͌Q|D:aBlfxFx7~qdϘ3b!9~daIyw^T]q#Iܼܧ&5OHT$nYAk
Wxh!Vp'ظ&%Wmfo=daADŇPr IRUA!重6VbΎSg."<MSwxj*=Rrm3GUI1,F
 B}i[5Ϝ=*T
1fr^!C_M<+]^<x	AT*ܦ;KkO'[v5YP8sLt07V5
C9	hyFEM`~r7_&Ji)Ia2lj=
9khdq^(7!lp,i_5;||Wİ5usS-m{f6M>bomNlCKhyJ&	^2*>+C5,M{>9>kSr[s'ͫ}2| "9͓
HO(Qa!j!Xw2R4]>^jdD`&M< 74,RZɶZ.`4c%$w3)5VKnԈU[0 
~-Z };|֛Jl~NRzچHS1U^4Ys$%۷*f\ΞJ9}]d~r	g+j(]Lhp'3!4lpE6uGK`׶Q,vgEjr0qn;z#ß+UM.nxxN&}D=ﻘ
!l[4H.0NGn6]ښR l;f(6aQ̴ODhBa~+g"}"ք\39X+¡ml(k39uIY;O21'Ҧ j#!؊wZo4E=&>հ&yV-INBM#U쓇hk!с˵{)cjs6]]}Z)'G\d~+y"<y6~+_n/U
wu1ꔹӳC7#NcBT!;Vnwk0^a貍qfuŔRY^T{g<<#|M۵LmgNrDDѕ1CyO VP8d.嵖朎3e3a}||*U[=89̳m<C=t3tHT΋Ưs<Px^BjGv/N6 uBG7Wwq)Y^L4D;gϖ$[~kv29$v6񕌫XDfeaxYP]n-N$jC^T,wrg,=c9Q)|L4nV/DoUǘفuvc|^,yxݹ!] 5)-HcxVR6)\D''75oԅh Uy $[P!!ݥ9eް7UޏGxAD])dF.QQ$Ju}PTŗJSHzM~ĀVl\VNN,'~Ь f*;M؈Q*܂tC0I\qڕn%*/S?Wrc5GI_l4>ǝ#}
$K@/eu2yDS.xsL"յVsǻ;4o	8cFXDF}؜`چ.]ř刵&pE!N402bt2Ǣ7nyRهgy[  
LH!a8(mM43453áT,I"c+!n:9zyǲ*5_4-C&8jK0鰠CKNRVm-w3IT|tpOTSS`٭@}
hR%țM#<cH$DvxّlL!Wz8J0Uov'_2*g]pfLdYCu\)	Ox՚E8z\Mn>hZd%@b}W"R?><%ˣ<m,X-69iRDokNYV+87H^1[jچR=D@W;`?4~1F	ެOyxxy*U  Ts}`%"vԱՉ.Q9ꖳLMsyV%41:ed.r/K~Q_s0
d4 6I.lʉPN>[,Z`P-e` 8%54@_k`oNF$XyekH͏
?'\ʑRcx!-UĂ52XitRd-'{jNتM+v8c9U:.;]=NBuń?0Lbp"y!|If~$21@EgN73/Χ\mEnaDٍɳAbC9m4P X#K1[]zKPM4O8lwҥd k(3WwiHo\]	}r~M`*FuѶ8pef洟ch#aZ>:{@6 +N^5?<k<yo錩Ʈ<:wÿB2Yv:h  @"[Eȧ~	[;k3VS%U|ZhW3'WKa7\2#@,쐡<+Վ+|/[FeQ	:S=ush,B_yU	=>> ?aWxAL3駉ƀwM!·P3aCm^ٮо>ǊA(93͸ToZ!i*sJ,N{<5;I\-Z×?ڬ?|E8FI
|vQzVd)^p97bϽvR1jΫ`~˳m2F̦ϸEn=WH+OBƓ.X'^[|rv3ƣz]* s' HpL^-螵Z3)-WY=^?m풪K"x.ELzZIxl[6Flq6xTR?Oo-0|U(V1kvĆMG. S]+Kq>-;adMJed'|hlz!u>7Te?~Vo}&}E]qo[BQ y*4bwʻ!)s{g^c2|-^-m]m8pU|E􀿜3/v_WumA{-xsEJEk>*Fyono,\y'cB=Z.(|.yxkN{kN@Σ\(F!q>	A[d
	'VLB]0|yMKG醈{M]xx}>mnH!{:'YoKyC7`8x#׋AnR@|
E] 3ǫg4oȕq$O擽F/Z5+ƵD},vӘ&Q1ŧ"LtE3\Ly/ߧz=[6eG,xMѧP\hn"5n}5VD).)>NIC[^~ѽJ{~K<gȩ@?2^gwɿ_l9\=7i7]m*n\2,\ñ}`CObs7_d9*o[\FFyQOH:=.ގ}_՝7V^ó9HM;&vOU}c/rN|PuED	Co@u%6Ǯ}̝+xO-<k~'y9̙賋[]D.ӫsP$v7k'>/ߟ?7<KWMt3Y-˒3E-mQ7vgw"BXOY%?-=巄<{+/PW2=ԯ3g/s55 /lyޱrM	H})X?pk_g6ѐ?1ޯK#oGK?+`uݱFueM7j<oV`~ʋ\Z/u	{1rrNltN_K3ȕs]lxBΦ`823(
g̯CV6I==? !hZRdڤM/f9B
oԳ30!ȫbd)SQJbTvvM`|gvqQ'B~zQnE-Y_"}Qe5m__ ]~2o4@z~d}E-f_"쨻)Gwn^+_px^O3^;3}!XDk=υkT$;C=z۩n!waItm(}.[9Ut5ϸn|X<m@r2s`7R ︵!3@-N8]IRk%Y!QE24{yqV(iAQVz)Ҥ^#oI:Cz.⚜)Um&`50ucB;|VxMrڏ=?hȯ9%!{Y0bA]ʶD$9 S;G!;cV4)Le
D	7A3隒Әod)(t^yamTkf@]uUr-j?W S	Rhq%bt45⵽GRr{YjڐsG8*M3;Hwz,>ljY
I,u`0>=7	4OIx<^T-}*xZZþ<E<mԭ?Ҏk 9kt4$jjs
>.Z2B֡ik2PZSG".KR2?FPh5vi$bmQeJ(t&7YxX&e5< 20ϘT7sq᫅%6*FSm}홢{G#)Z'e@\74~3l,>'(vFe;1/mRw788x4(fUS*LZt{wڬ	n99v>-E-nR
VT;)^My>+Ƀ35dܞ^iwŞu[x"H2OF=8+wbOcL['M&{ĉ˰bsppWmR<Q
"q-8fG:==^8^`ZAT=X).JTĊ]T-yl]{3ƅ2|	;%jǘuP7=RתJwr\f\$S묕J.,4x>hj[]k|D,hA,НNhx< ybxy!tHQ06
f#3w&zB4ENXbK*Hs#zZMY|WSz^~JAY{oq}{c4jbI{wm xWBtώ@q= nW	R3ا#⭼(0AasE/OsOJC^/M:V\/Uįp"C[syvAГKso԰%72.\mjF10h*eV8{::p+%<d
ۗřٝss!\ͰeP`">=Ŭ3wGXA!'=H!tk٦<huU`"XMS1D	{nxE㯷'=Uxy9V̅rG?Jl/X˯	Si#i`@4Sen}(@Ӂ _֊2}2Ry*&w{^IaׯC&<	>2>mO2 $,O/ܫx:LHEXVp(kP[d&
	4DNCW	#SyZq'6k\x] r"vkNM*DF"XsɑU#<֛+~nKxV^j eQzTnU"p9O.u9Seσ2c9ĳ J,QXet>c0*
B%2[	e1eFswس[bf\p&qճ Bu@Km6ErϬTuC@B!v:bqn!q<=?j$| `(U̮t[axy*u|O[1FA7F<]Eѝ,n>^?W_4yu4*iB2`U#L+!ǔ2lG!éyFo1|[K5	0< ˅e[L]U7N;ٹw̳_3Ac~kNy5E՞zsU[-ޛj;fiKHssC]vzOhX v/R$jWW#oGUiuM?k"<rѩncz*zTT{aVQ1F1=8SүE׫;?F8h'j"zG-bȎ湏M62܇Rp|e32L}cpt:4J&sJfy7ǛOyPmNw4|je%FIv%<mj̞Lt=w#<^6ݎ@0CKߥDo>?(VV9TC1h'~f@[:VPJ]}Nl>䶉<^<S6CYrIٮ29`8<;@X"X&Q>I)0@˒(u]ǏϮskSSRВUQZy2>ͥ D*TslHvvdخKfN|bmm>);7d_u^P^M3m&tڏsE~
QW`ElG9|sC9#{*s_MĲ):aʁChSscJO|	39[Q{=G{Pij^kfRtJ9h38F9eV366̱qcbPP^*&h@8/`Ux{&<F6̪@ɮwVT~kncܤ% 1}<r/#SOmK[px-/C_,d>`(6G$.-UN~ϻCRIqfn{VfAnNzyxJܹgZZN5[k#8;zZA[ wͩiͦoXT]Lڐ
<q4#7l7eCȽD͓BfT	4ѓBf~]|vWKTKV6qd4N]dIbπ><Z	y_{n}S`m;az]|om"%M*!uܛғgRyS"	}Vm~}f}.|SzV5=-vƟ8+na*;⸷d$^l9@{1A9l&!u hM"{@Y*Iq\IYX$ZJB@{n۝m_m{ZPRo׻"9n_\Xlzcz>s~3tWwAOvΊv#Ө6X~KGցQ>lJa^͵̀6怠p[Fki)Z7d݌D7E׳>U?ǿwz06,rN#q`żlcDE[0 dq<jvڶ {ٿ..|yZfbXnwv<_^m}AA_[&ao?x3z䶟{'nW/G9O??4oeBme(%ɂ(ψH]-j-\n3)h:TXcR8BjרPI&zySomr7643$=VtP
@\'n^)p-)̨»d*RT]s0⅖s_;n-A$kb+kO$ю*,eݎ(؇&Wq0.dT300OC>
Y^P]falCFgYx(TR029K6mhm LS4CytQ\xYsJ<bw)ɥEKaך2"-cftH8\zv꾵!\-
!4%|DSL=<{#^FU%hc}Հ^n>i<.s1zZ1]df&gYiqf9D;Dja	9CB3?'Y	$,"l/Czr_g'@];Wv/ ȋm)FP {St؝PԸҩ0Ԭs\tTϏ0Cs=J7{
!ۊ3;1KBXM9m!e -h*`MQnsNr׎z}Nu]~ZK +)i==wWww^­5.`+%c]8;7w4l;0Kmg[V7;7#{z"<GJhD^},~@?A IDuI3%`tJe0fO۪FVVH[W_j}LCƃ_9WKz^{Fjw~loNK,k!&:Tأ8p~oCle9>PXC"3|0𒒚۲&Ndy	JQ4n^ny}~
(.+k /sn]/Lxۻ܃ob)x4NvYޮHw=}'?opB尔}: J+ă[r![£ĺ.q(+*ྡ`;Fٞ-LQ-K^Bw~_$Z^J{V}l@tEp=1*dkܶtT;̌4n=eZx[	I_іdmʨqZ@݅XRݧgnΞj/#0ewNi!ԋuaS= ~^
|;/6Ҟ?G>G)I{znҞ@]R`?øJ{:3PҞ?CH{ݦ4&	#$PM_HBvr}oSE,=&Тq,W,[:>p"ϾlVj&bJ]'$Sܝ%d``
$(k+CźêTZ,9Gp#"K=tDƴuYLX81+ۉ'=ru_؈}_`S;0>A{[0>SE*}qn6_GJ^ǹyqsw	uu׵sqwq}/|}FzziPPXfZ6B^`˓ жGvU4b+F.gA`]e)Kgce=n7/9l͉`taUz?IVM(S,ᱥKpġЀӁIrVj3|1VI\3_n!_7KP`d6<S_2D3QMMeTQ8e?dZ%)9-x*J6˂V vGfq!NҀĜIuc=/~2~2 g0L$׺6:L}'͏탹$I<HY;ɬ y>m@l9{wyyŃ!QaJq-ua59<<IubYw3;8T>.Wj/xTVݸvo	;	%Fl%]b^^Ăg-4+&CY)9a CݥuL߼l蛬	vC9voêk*sU^\3{=agy8suT[)Dʚ=5~2L2"wXe%<
{gBk8Yy<KF]a.Cq
!EAv$v˥@)vuF߼l; 6\[vݾWBG.C󵺐ItDqF~SC$B
L!z7eFb):7$9Z6B.P*(bQkqMLВ<@`TD2MIwlpˎ뇛h2kGf%S@S a43Ȓsw,IR1rR`ITFU&߼qK߶Pmfmѭ!ys	`IVMh}dbbGG^->?^7]c2SVqI2Kx#2hu[?rG"OFB$41o]ה3qqK@1x/39>|O&tP=-N[q%I|0	AHMhIBD̿5g:YYY8'϶.jJgvΉMuQtl	a}jWStxx.SWڝ\&QuQs#S%C;sZ/DcV?Y+ckS5TSsIII˭f7>xs	.FR03ڜNc֯!;}퍩>C_>G?B~PU78E>w*Gr~f%e|CDjՠ(}\7ܿsL'"l^tH9.XG>g)ElK.q:n*<0>wujpА

j/?[.w~iDt
GϾp`S9XuM7C`~WW	"e/nc劵h):OS;ێ_~<1%a* gom-҇w6U*9,=0u~
UxpvnDd~ǞXko^okPXBy+H"hs>4_?[%o+
'u+<v/!$~{6]Xq"г3䬥q4j,觳G68s"ῦAfAxt|.XЌW+KǇ*WO860$d',1LͲ	>)u.i׿(r{u<tM΃GO=x#n1sNx|aC>8lVV
dgU@lOp/D7\W~þeh9xr=0DMcHs$g߀78hW(,1G0mͪ3`.~{y|jUgMsdA0E9shAf>sl4!h-2j))q#kE^?P]9BAIDa}Į6r{9¤5e"k&a4; 1s4>#AN:C5_Tށη ݸbD
acEݜɻR^TQ):1%c3{DmmM<((@;^L[Z܏XndfC(Rg/eܧ3huhYY	BRA:]AH^'(Dk`@_YNz*X\I)fp+s<Pwa1vX1ќ&$[;iXjR\i>qj\NHV^lu]epZf7gmk4JN)yJ+#lުGlF71o|_#%B8Ebp'2wtXL(lQD!Mb,~|*t^uwQ]s_ ilz+-ٚa/)_jې>#_,MmgHi1	2{69~Gg~tOsdkT0J$iuؐmMֱ"@:Ef?Cj˽vy˺'i?IiJn2
Rc!^_vAנWv6I!Q 砀0Z˵RɳW5ca!\TR:םUB,R5#Ue`]I\ۊny(?ćJ}PMKizG#-u5ʔoU}8Ètjŉx7qaｪ>}r&	B݇^7C\T-69`7&Q0l)tYr®{ł~}27+qW*jQ|/uLU3$F4tZDB,+HuLafq9)蠺O3u!LΜ:ᰖ+mk>swD[# :Fv{lF9..ԠD#訴x!6\!?އgV(lYR/qи	|4^)$}5Tׇ\R.8̮؟3hbIO	f,"XF#Dy?SL9pfO"8J #xFmdɽˁw՘~-2iZߴoyr%Ǡ8ލ(/(E/c)F^}FQPI{kџ$D]*\4/py;J>{t|u~F.;mDYGW|x	x]4f q4ƅ!N˝<;CbJ
3`**wi&o+̊lkcà.4W?}ڴ|gyq[BÀ(^_{yfP=XSs+l^bP]: _`]ѷѸˢrGLUMyNtSnDLD<"	Z5?dTǢY"?U
8ߞ;	-`: OVU%]`f	l1::7A3+}gt^ߒ o`}xZr6ΧأHj,vvN:DKIkU$L?uIn IPfZLE`X,oK".Y;X^&a,_+ziPǋ 9ßIaa7|,I^\%Kk9E
6]xp>5t_/{#gO)$AvA3T/IRq"LD% e)q@0<ę{'g=MCW`ɀ3΃8څŢ%,ݔ+b8<EzI9-}Γ3C6)KGQ"9Mbvjш↢\/.^=UN0*WxoY͘wm"j).RtM BRK܅&47Sj*5fǧᨼ&4g1;g9a&5]|6qhAǢ/C7W̚Ү#!H>I,3!@rC%q;837e/H4_ӌ[:(Ɏ;{/ƃyddi0Y!DXT7#ןϲ8`+?6$`ϻm۴z?람6_$,u9Kf<1Ӗm6ڶ+W!r^I?쎟pO : :y'ϺH1y
C	g&G۸z<t0~wit{=qC,kB/B]>Dbxx=6X*RmrvA`}	4Iٚ-鏣#(KuœD2u`ɥ3(]vq0clp{i[>1R5JJYqN vƯ'8])M$܋ܾ0-"R!o!AQD:\ATwLԪK7\nZ}.ֆ;*txv+z߁S7g!xhAh; p/31j^gY~x|<8t>ؐOSo)G:Kڕ>cUE'Q/\Hf0)wSD`ꤣ 	b^[hT#fvfc>^?IElnB5rʔa0CƑm41EņKKH/E"l#aP(ݜDN"9YfHviXdmɘ[2cAɁ"T\hjebf.b<m+^x.SK6MFiF} [rcѐ?&9DpW<wBաZ#AG9Jp&1]n&ET3|9y0zu"J!wO?Ę_6uX&<w>wn'فZtE922uL 2'GSB4Fm/0e<(dH+fYn[7?وshx~AE"@0XAѢ_.CWp"FöJˡSw&)VJTVhDʄ٧(	G׀*F2f0*RO|*gK(fmbiy/H_'B3QZפIjoHql<R-z	Hѽ'ɑ5cZag4_hQ̏Z?>	3z>i<Yaрy$떜̩Q%h"."{..1%v.YQRAP(n$ɮ(c$N箐B`.Ζ> Z٩L0~
.x!szT5ga.0!LTdʬ&;JdW;^d!XPe[Nnnx`b1s+r%>a!PDhAvQ	ņo!Z's5YzC*59=kF#.ÜT(c<E!Ũ+?UL@$fl*%
uR+3=t$xrC5Յ+ts7*^ר쁨lҕwcK ㊴7ZD548?+x?
/&꧕ό޲ozW:pm}%L|,yi}߈Zz]B I@.x*5,ttaaB*%[,Py?MEWjۯ[crz#'G8 M'Ji4ǎnQo݉|MU:(^4}0w{+~(b%,EAtE9t2{K*\Pfk?WP@?~$$	z)XL&-I@;ɒt>#X_n 5Fqz[Iqp:j'j.	T)Nה*%߲sݲ <N7Gd`7jFCKΩ<Xt7&.(-Z.(n*g%3=J5zƔZ%ȃ?84E!FDwS僀^Ć('LҭStە@n^f.e՞}S]~S螫<3ÄhU9O.t+Ma+?	lfӨj[lwoo{v%ӆ68YjS",]XrzG:2lZv"ӗL,X9oat~}k?L	e;7`@n{yi)IS)B]4+9l=y /xVmoF~beUJ"5/B-ib1)Kuq,]8gf  ,+(5F)p?	hDBy3W0t $R#4~;Qb>D	%D!S=~6[KkqMi]vKӟ]˥i9Fʱ:qnxFˡlnlǃFg=,YoMKaY_ous6^Vc.gXkPS.{fW_x[nhhp]e[4&m_8+w05a&N'IfLZ\/<b9e`M9t
h9Ro[4e
wǾ12v֚=aPל9ׄ6XYprbʰ<;e-LIQxp,wgL&F4}\k"Ǆs.x_𠏂NTgsؑ3/ʞ|?>_%lqdHƱe"N#Z@==Dp:]h,hcaRzWe:O[o2m̴>v!!A~~t-ǜГ `@~#(|P)ǃuɷ^o<JeX#x.8J8G5A;L{~<b8c
'O-%5-6ѽ
)]PNkkc]ږs&0
0鎦TMvj~?!?~7}a]:+-^)|1Б
xn1H fy~5il>2eyEDa#&)<V9EPJAsD:0y7:`wjB;GAOYPzs32/
=d=/:*U'	+I3-x& 40000 superpowers Ovi`cȉXu)wڠx@ 40000 plans @=4]KFn'140000 specs ;X瞨. ښzA%x340031Q02025054-IIM/J--OLI-MJLNK-IMaX0G@fZv 1핢|o6D3B7%i|Fhubjة-%5LN,pYN9l&ow-<s]3bcTg` q?v5m9 W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 ]BAmy.idG
my.idmy.id`)$_ YOUR_TELEGRAM_BOT_TOKEN/
1234567890 0yourdomain.comP 2)user:password@host/dbname?sslmode=require%!<8x(\# Upload and Deduplication Feature Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Expand supported upload file types/formats (stickers, video notes, etc.) and add SHA-256 / File Unique ID based anti-duplication mechanisms to save Telegram channel storage and bandwidth.

**Architecture:** 
1. Database Schema: Add `fileHash` to the `files` schema in Drizzle.
2. File Type Parsing: Update `FILE_TYPES` map and `getFileType` to support sticker, video_note, and dynamic fallback.
3. Deduplication Logic: Add SHA-256 helper in `src/utils/file.ts`. Check DB before running Telegram storage forwarding in `src/routes/upload.ts`.
4. Bot Listener: Check database using `telegramFileUniqueId` before forwarding bot uploads to the storage channel.

**Tech Stack:** Bun, Drizzle ORM, PostgreSQL, Telegraf, TypeScript.

---

### Task 1: Update Database Schema

**Files:**
- Modify: `src/db/schema.ts`
- Test: `test/db.test.ts`

- [ ] **Step 1: Write the failing test**

We want to verify the new column `fileHash` exists in the schema. Modify `test/db.test.ts` to assert column `fileHash` is defined in `files` schema.

```typescript
import { test, expect } from "bun:test";
import { files } from "../src/db/schema";

test("schema has fileHash column", () => {
  expect(files.fileHash).toBeDefined();
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test test/db.test.ts`
Expected: FAIL because `fileHash` is undefined in `files`.

- [ ] **Step 3: Write minimal implementation**

Update `src/db/schema.ts` to include `fileHash`:

```typescript
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import { bigint, pgTable, text, 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(),
  fileHash: text('file_hash'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

export type File = InferSelectModel<typeof files>;
export type NewFile = InferInsertModel<typeof files>;
```

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test test/db.test.ts`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/db/schema.ts test/db.test.ts
git commit -m "db: add fileHash column to schema"
```

---

### Task 2: Extend Media & File Types

**Files:**
- Modify: `src/utils/file.ts`
- Test: `test/file.test.ts`

- [ ] **Step 1: Write failing tests**

We need `getFileType` to recognize stickers, video_notes, and safely fall back any unknown MIME/caption to `document`.

Add tests to `test/file.test.ts`:

```typescript
import { test, expect } from "bun:test";
import { getFileType, checkFileSize } from "../src/utils/file";

test("getFileType handles sticker, video_note, and fallback", () => {
  expect(getFileType("image/webp")).toBe("sticker");
  expect(getFileType("video/mp4", "video_note")).toBe("video_note");
  expect(getFileType("application/octet-stream")).toBe("document");
  expect(getFileType("random/mime")).toBe("document");
});

test("checkFileSize allows up to 2GB for fallback documents", () => {
  expect(checkFileSize(2 * 1024 * 1024 * 1024, "document")).toBe(true);
  expect(checkFileSize(2 * 1024 * 1024 * 1024 + 1, "document")).toBe(false);
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test test/file.test.ts`
Expected: FAIL because sticker and video_note return 'document' or other values, and fail the assertion.

- [ ] **Step 3: Write minimal implementation**

Update `src/utils/file.ts`:

```typescript
const FILE_TYPES: Record<string, number> = {
  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
  sticker: 50 * 1024 * 1024, // 50MB
  video_note: 200 * 1024 * 1024, // 200MB
};

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

  if (mimeFull === 'image/webp' || captionLower?.includes('sticker')) return 'sticker';
  if (captionLower?.includes('video_note')) return 'video_note';
  if (mimeUpper === 'video') return 'video';
  if (mimeUpper === 'audio') return 'audio';
  if (mimeUpper === 'image') return captionLower?.includes('gif') ? 'animation' : 'photo';
  if (captionLower?.includes('voice')) return 'voice';
  if (captionLower?.includes('animation')) return 'animation';

  return 'document';
};

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

Let's also export `computeHash` helper at the end of `src/utils/file.ts`:

```typescript
export const computeHash = (buffer: Buffer): string => {
  const hasher = new Bun.CryptoHasher("sha256");
  hasher.update(buffer);
  return hasher.digest("hex");
};
```

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test test/file.test.ts`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/utils/file.ts test/file.test.ts
git commit -m "feat: add sticker, video_note, and computeHash helper"
```

---

### Task 3: Implement Deduplication in HTTP Upload Router

**Files:**
- Modify: `src/routes/upload.ts`
- Test: `test/upload.test.ts`

- [ ] **Step 1: Write failing tests**

We mock db select queries. First query finds a matching hash. Second query finds nothing. Let's update `test/upload.test.ts`.

Add to `test/upload.test.ts`:

```typescript
// Add imports for db and files schema
import { db, files } from '../src/db/index';

// We must also mock `db.select`
```

Let's update the mocks in `test/upload.test.ts` to support querying. We will add a test block:

```typescript
  it('should return existing file on duplicate hash upload', async () => {
    const mockExistingFile = {
      publicId: 'existing-nanoid-id',
      telegramFileId: 'existing-tg-id',
      telegramFileUniqueId: 'existing-tg-unique-id',
      storageChatId: 123456,
      storageMessageId: 7890,
      fileName: 'duplicate.txt',
      mimeType: 'text/plain',
      sizeBytes: 11,
      fileType: 'document',
      uploaderId: 0,
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    // We'll mock db.select().where().limit() in implementation
  });
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test test/upload.test.ts`
Expected: FAIL (no hash check exists, it uploads as new file every time).

- [ ] **Step 3: Write minimal implementation**

Update `src/routes/upload.ts` to compute SHA-256 and query database by hash before doing any storage upload:

```typescript
import { eq } from 'drizzle-orm';
import { computeHash } from '../utils/file';

// Inside handleMultipartUpload and handleJSONUpload:
const hash = computeHash(fileBuffer);
const existing = await db
  .select()
  .from(fileSchema)
  .where(eq(fileSchema.fileHash, hash))
  .limit(1);

if (existing.length > 0) {
  const match = existing[0];
  const responsePayload = {
    public_id: match.publicId,
    telegram_file_id: match.telegramFileId,
    telegram_file_unique_id: match.telegramFileUniqueId,
    storage_chat_id: match.storageChatId,
    storage_message_id: match.storageMessageId,
    file_name: match.fileName,
    mime_type: match.mimeType,
    size_bytes: match.sizeBytes,
    file_type: match.fileType,
    uploader_id: match.uploaderId,
    created_at: match.createdAt instanceof Date ? match.createdAt.toISOString() : new Date(match.createdAt).toISOString(),
    download_url: `${config.baseUrl}/f/${match.publicId}`,
  };
  return Response.json(responsePayload, { status: 200 });
}
```

Make sure the new inserted records also include the `fileHash: hash` property.

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test test/upload.test.ts`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/routes/upload.ts test/upload.test.ts
git commit -m "feat: implement SHA-256 deduplication in HTTP upload"
```

---

### Task 4: Implement Deduplication and All Types in Bot Listener

**Files:**
- Modify: `src/bot.ts`
- Test: `test/bot.test.ts`

- [ ] **Step 1: Write failing tests**

Update bot listener to support `sticker` and `video_note` in type list and handle `file_unique_id` duplication check before storing.

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test test/bot.test.ts`
Expected: FAIL

- [ ] **Step 3: Write minimal implementation**

Update `src/bot.ts` listener array to include all types:
`['document', 'photo', 'video', 'audio', 'voice', 'animation', 'sticker', 'video_note']`

And check the database:
```typescript
const existing = await db
  .select()
  .from(fileSchema)
  .where(eq(fileSchema.telegramFileUniqueId, file_unique_id))
  .limit(1);

if (existing.length > 0) {
  const match = existing[0];
  const url = `${config.baseUrl}/f/${match.publicId}`;
  await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, {
    reply_parameters: { message_id: ctx.message.message_id },
  });
  return;
}
```

Handle extracting file objects correctly:
```typescript
const fileObj =
  fileType === 'photo' ? ctx.message.photo.slice(-1)[0] : ctx.message[fileType];
```

Ensure stickers/video notes size limits are respected.

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test test/bot.test.ts`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/bot.ts test/bot.test.ts
git commit -m "feat: implement bot deduplication using telegram file unique id"
```
`Ȼ3xvW(ί8fII,%J,Y% ) G hn|ﭵKoޟ_П{8s @JvUvVDy}tOdF8cqQ?zq&~<c(LU?.,l/xf"+]_|!|vx|/Nv'>.XQ+O:Բ˒ыtjwً{5cqw2N#T$j~b|qktߦ8ǣ\L`˸~vCo5_>shEwwsa/I>.4qtB%0 k &8{"˹JƗ"WqG'`~&iН{x8 FY:Ni_FE q و}qz kY7sq\|ֈ&P'qѰrlV5"F)7Ŏ瓗Gyu(5L*F\ )%'";װڏ#Naj#Y<HAQT@c4B3bXO$\8e%@i>??'K ޝztd4[Eu?K~	{yVYt%v?a0E5g? ԇ0_^<= ?I|O;m p\Xh0ݴ*iv"3kZ=3@5Ȭ[(iMXuyk}4D~ [Z<8/^́|,q!&C"h~">LȂ CD`p] )q[#^&.!,,G 0hOM3`cj6!r-au sE	۝-
rrw|pJ_p 	Y(0/FhOFr*]NÑ  qRmGWy0#Kſ>aţe=B7ynjo U髳S.1ﱃS2 J#(vNFw 1i7ioҏ6S C j?۴p3(j$rDG"EL[_b!	tńNpRGY,;ɒ5>Mzy!DvK1I& B{>oEa(+
@mSdTX! f?No4@	l'ooG s1p^ Uj}g8Mt>,Ts1@	L(@2VMÚ~XG2s:}GB}_ hV_	9-@q6
Sk2U+KT0́bQ8 h/; :N{f^%rlZ6ߺlR<v@Va8`'0q[]j.	|asw\AeuI 5r3"0T9`.WK+yk#^`}1g4f\ýt2ŻPmM ,g%J40٭dh탐١Ja	4_0ZatU*<;rd4vA[~+2'văNCt흜#5xw9$)lTg.5BbZЬHr~m=RcZ\XVgvq=nCDr2`M0]F:=?}{|ˣ`qgQ3l&bΐH<u!A]2Q\>/%hX]	a#uUʨiR Q{<Gi"4r%P>Hmq+o-O)l	S08bHc=ЎZ9i8 lh<D#<~Qij9 5!R@	v-tC)B=ɏyyLb@ȔXK	@*`Y*Qa# iٚ/iIшAfWn`7d7,k!Z-l6\$]7(־I6^Y)Ź8SqRчc8vA^qPX35D7с?D7HԞj{KZLZiRBqDG0NUq$V٨ڦ.Q]EgYF1Jdcؤ&-Gؽ@r8}~ /''.~vv/^9@JЈx^:x;$j7nxqjM?:{.{΋W׽ャӷ[LVXm ߒj)Mvs{Qo h@
Nդݡ|-j9[;%KBYÁV*w6߲^/_>?9=G<SgtGBIz8<??އ[	tl>VՈK Ԕ`s2"z@`|@̈́Dt|g9M$z [{=F;KѕqzAmQ'	0'w H͟Wܾ;<21Az'Ǉ;}CUݮ?x~ 9?~pvFDBKjJa#>_{?lu4F'IvfDu5}eC:B+)d,`Ggm.^IKpkv$"R<ITn)J[E%]h:dli'uq<!.Lk\DB'Zḅזs7:w8FB}?^t3rڬvFq[d=dLږQąQ$P.u uj+zGVfE	>ǤUP/T(Ƴ؟rJWܱWuی򚶛<KgL1JI<w8K3<b@<-]$[7QӯEH3ԩt^Gh2`L) )j-1$(nHjoYwD/Qͧ[= ywe9ĖaWTk눖AǛ\m[lmll{:M>QN
V)ʭv1j !@>߷5gGGCrQ6ʆچHz4Gz4mjaz5Ɔ(ffMvJVZ`'{~T%t74-`nC9	}g1scxL/_h=I	_9k<5b|)RM!'䛗x-_/+5aI(D*ghMǕ- 4*4l[/W-52䦝4NLT5\9yhX7&R`<&%.qnPGW
Qa_1xƭA@C,Y?tT-_256UvmɽBM/Lk%GTîWwv"ٔ}3>n?:U
kP>" j}LK
0Iuu|Xcٿ/WغmvZ.u_okFwXP甂ק!b~;=b%'V2s3)B!(vU-{ev'fj5t5/Ы$	?kJߟW K;A_ +2:ŽU\]pbà$MZAvjHEr898}te>`9+b<X7ʻQ/n(G=XO-h\ɢ_A2tja~*n^ٞ{3Xt
s=۵n9Y$jRi%aňRJR_Z%JO8Kn8WBm^iy"g@5قl1	+m:!\.kG}M/kN7
݇u@9(tPTf0YK̚qH"+O`h3oo&@ɏ@yg/(r#v*f*#'#5EA6 7?^j'w-*hnQqhfW7UY
bjUTӪ5׀9Uhr$PQNk59`?Z].2T%Y!</z߹'NsgͳA2j]7K`HzaB:&8ڙ_@AX!X6> u69շF;͆AoĭGh nP{]<LS@CDrX%ϩ(t%΍{w/Sŭlp}YŰY MVxx(*Tm1&,?oQC-*6%㚑r$ZnݪHevAK[OkuDKb1!OY<~xPFQj4Lpan<]irz[Lݽ.
Y@TV!I\ӈ{JRs4	yOQ_QmXK6qA\Uˁ5(#ޱ./f>U4LT!J9ugM|/ϫLՄ4&]͇Ḃ ƓHSLA@QV^z'32'gֶTRt>RsͨsXʿnE=HսL=e]K"7,%F<BHTz	U $^}H`xNE׼kHQ|,w;2\ 5ɜnz|,RdDy<2vM@ʊEHOWӀX~kA%iA;s]NC؝YՀfL@2i Ad(E/rqucQE)+">Za}]_fhdrM<ro/-َk(8><8jyr9}Kwo'.G%'+ؙ Rhxl*fyTFeiy1e'(uǺ}ân߫
Ye8ߺ!Sm\zi"Ո[uѦΝ˿Uvc*瓈6U R)fףq|tn1H)0ĳ;{g;+F q?n-5[-~$cfat$ɀBax]r(t',=K݃o 
YP<^ڋ8fD'k=_ӃG;xW[ykڔD a2/"ܝI:ɺG<꧰5K
S' [OTgM/M&֩ס;cPI\ JCtc)t3[ՌݘͬY8=a}zq9z'hBVW[{4V+)m EK\rH.ɀYqeU%
7ǼEWVBx78  -W#/Po8ӝ%vWU%}ڑ XEؾ.A~OnDG1t02>fwET5w?Y{p'zzYIGYM"^G(<K"g{Rzy?GEwekW(1gZYFcIm(XfOo[+K+\z$-RG9{<JuhS7O,^eDV\X ڲX}7k-}=N9Cj/}Z+g3 :!x@վ%LYyTqNX
XKuKKe~,J_aUV mT/6TWָ՛pUb{P^T݆s{9˒?˝K\OށTxQ{];&:WP3\ڧL[ʣK\:PVݽ:y }<mj勓$~pջ]pC]5að͍FTpc`-k۶&#GO~!D2	f"R0%cP jІW>PPuBCtEoq	vh]: |:;~~GY6^4ȃ'4H'V'Iu[4OtuQel{M,֔Wv^gA4ĝ~5?G'`JW2e `t
Qg5y~>'mS|%K:Dؐ]otg6[^/R
%kZ^iFnDggg8QQYT:,"G9k"d;	7s!F}/8'ĖgӋ:cqJdk7VmX,]EekJWzqIUtH~<L0|МXz/RbliZ[BvS2'N9#Q΄'Zd?rhatLNϲ4?U*hQXڱ-W%&J;hʋRYbfs`61[ŊK->g99(A=)<}2QLa\ZW%{ bMhT,Oj6pyb!sMG8&HMmBLKfi^X_,{m9i{j~rͬ,s4C0WIQ5UϿ^㙑.#yݑL,P5c՝4rodJ_a3Qu6φEl@	Kht2H]
GxY)Lj	쬘5Ls>iP-:#[Y.3!ҭcL_,vb :~]Kcxǎ*X\Vq٭n8jKbL=}AS4=Z?.UbyɂaX{2?p0,H:N'@Ӑ0;ÌX#'$5R̳zT957NiFϪVhWs5T[2[*{d*
$Na4]k^Ukb[-dW\wjض<E=A7{L%j:v5+dJ,M#
lN<↴kWs-gQ$GaO((f%+=&)u-;;$G&߆5{9t
H-R`ssaOQ!>D|xy'm۞UnJ1ޮ-ðm^n%&B˭7GW;}rg28hZ$T;(??[W*wDdM[iڿׅnI~jpitL4dF鮥9YśNy7y=BnvԾ=)xs }06?.msEh~nt𡰔Jݱ*8>v|Ó֬z7urNYÃʓH.;3@E7n*? gGˎڛO h`arsiQ?xvMu￐|kQzf4~JÚtV7VK˭Em'&,}VɱLn4U;v٢"U P[8fKh&
`=f.w;#߾م(֑7=/nWfWF(Jf!=滰u_?)(iVϦMԔWB,zlÙe ֖U6AKS1}dCsHi*bCq_Դpg{ S,&LșAڤCpZ:mM4^M}!Xf.o,[{0N}rss5$<# erx$3C<qO[STjq+y˕V~ǌ#j$	]<,Y{s8~QoMgSJ@0-ei౛*nmH(41@?KCDQd߭LW*MyId4[sIl2C|91)[Yܭy&mel鯀I~49	@V?O`$j}\kdNF}`Mɂޙ.]=)F=SLT>rM)}4g2ObV$#Ml	=r/< rfRTOL
.^I/qᫍN<bOAOhs~C`tK~yWYR9qb)/	hfZvA	o*P tU>pc&tҞRdb.uٚ@qnp:쥣<Laoyw{f+gunĔ;%|J{p3sr4fߟ蚐z<tꪹce]k%ly*.zQP%V6	7R⣺< 㮝pFd607Ѩ/Dsyv<TW" fC8I9-
\xЅ]l7k?l]ۡU?.1B:wTktHq80wj*
K('Cdje}7[wMd]g澿AEr~^80GD<{QxhUR:Ho}Cɛ/ 5o&S][$lEI&ŲC9+ǈuD: G++ːOI䧖![K'@닛?@D:3u=?79lTR(QFR@0uxRbXulN̂}IgOI)Q!r1QYJ+w$,~8BDMq+VZpΕ5ʹ.O_iyWg<7]QI#QɁOA5_PݹA *2ʠU9'5h5|;qb(\&=)pteGDZ\q * 9ϲI]RfqDEҌ/ȕz($+PкNCCѓPw\n)>oqN
0)r*\V߮CWםpTJ#p5g.*jCoABL($;0\S'
ֽ,ooGWkk13L"f!mL2ݥUNSyQE{ºE<\j]S`3L.?P*yZAi{P6G54Lj&{$<"!V~,>]f<SSMX, gefL\%){|C}}#ߞY~,TzB¡n>[5;꫈"
*1¸w8a7@uի ˖P:jD8jW%G*ì;'0]V/̯{^Q=bXJOSB}_-~.3`֋SMQt)b,Qi&/!&(ztػrCs*(u˿Nuj%73A8qio掘7ߥv}m1'Ν/R&SNHޗKQiVuAnڰGz𞪾_CmnA>vA\!QwjMVVF͊'g&j2ɪ@Gzuڀ1[f2)ҪV>-Tε`.厚p]*4c+<L2m`ȹ3|spZE$ZHVU³p4Q!Hh䣸˗5$\SHDt%H%^9'غUA>ּZ;ϯя}:2xg^D~PK$g'Kf9G-'ZMqiSXٺjw+UŌ͔hNMBܶuno~/vnY=g'ߍmѫ'S(	[L_FyLR:d ZX%H|5präz3*kUf7aeiM{M
ܿmw9"Lm7B9 [i?S("@ZDu;s6=NܸN>+/O-L n.wvLzqcopZ|z˧?'_3Pd=Dz!$6pFy?6{P	W;NrdʹN0c-WyҩKuZ"v'Z8nVɸ;kfe3a?W~QTᑚjhiIew	#@.;:I1y CD~9Ԟu;x)iiӎ1|ĚI-}7e>No<$?@th NF$#I.E(p#P>17E[} K}5~Zj|Fxs\|?hv {rW(?r*?q˫aBKoOUYѬKGyѻC5yH3yrM(}N?.[z*}xdO];YNmAR3'"4{	5^dҽJ	3>eXӸ5 mca͓l%9f֭ͣdӷ0ZM_N:t݂LA1Vw6]*a4_5|<< &PCՎ]큵
}7A+&q޼B %{z'g̜)q΁8YmYPy3
V:c=Nx^nd&Lo l2T\_#l&BfS-5;I͹|NϦx_LhìA/EФ:#yYvdT-k9Ozr@iPbDm\Y|<{GJy{ޖ6i4%M3.蜾In`Mv+=L4E>8vҊow\ӱ&BJE@2c1.Iu1OQQ !<-yt5uB7`z5B'bdTx:cvKjVrCC.')QF|!NJȽTjiKό" ?ìDEe@5^K궦`6j/2eUS@f֡Fmo(5JM~ ݺ$~U tO)}bݤ$EKY):wRs9N0DSnNҏ``^X0ufuQS:Mt``GŋiIMw@^MKpkQ l<Be5ϒNX4}!,kpGܷ{h$oȗ@LMJeVMcVK=z;L#8%NYLMp28(4Ul~QҠ)p]M[|;
ӽwH5߁a|wqҙFE\Tz=3rdbfF2F#>j ڝW]UE^tU&Bl3#0S+&xt"cM|j.OϺEQ^{\WYw87<WUziwF;U~T?]5T&D8
~ 9m%mQҷiY+gL2t`d* u4gm6'028ҽ*/7 ?KMa?5A5mlba{z;UG>P(SJ]FtE]9NEyZ^/7`$*{GzNz*OTLg>97&scux؍U].oگO[IҮt]1t&ҝeAqz/X
[iW#>ܔUZ	w2H>o&}
OByA[_(w.'-We(lb7:]-%9iO]YqW4M89`Fp`Aj4*26sbRaU=JJ8GT_]RjN1z:6sDd cO.V+s_uf+62o. /Gs̘jc'|3S]b1/Az7hՈ%u[̖-F"]; vSOI^ׇʉv[MŜ>8xً8N'jKa-=@yCs=cuɂu[[ BՒlzuкx*<V-'`jD׆&z&T[΀O^?4b͓&)1
+=mX*f*(VnGdtc~_4
^u*RZZnJta]ZԷl4TۚҔ4n)Cٗ1914IOӺtSr?T5szaG[]A"9el=Tܠ..e{ZJ1sc:3| 7(Z#[7z	ؓ	?MIXYͦ=aXqJT΍;:$uɨ!SR\=NgR30*p6ִ6H1&I(K&rHy7dfϔYPxhJw#1J$u{0}T|'=@?!'&XyC),b t^4ZP7(Q2/JuD[
h=g#Ou̙{εIGM?ޕ33Ұj"v{&)ߚȀd8o|/ef y!r n:w0f`2M{mc/w'}dti֌. V'0*?19eш%<hYzC,Vc;<L5Ϯ4;3e$o2p	kTe L'T|Iy'㳬[ܻjIq{gZ#^*ĦaGD~\g۱i1s	WL(wuu¬?)5y4V={𸾒V6~*2{xeGE%G=/F.k)S)(v.':=v:qMC	>~º̯pIy.>W~6Ǔ(WqAg+5٤o4k||DG[Rx9(2hfzMPwFJUt,{0yN цd¬8T|OAO¬'(%3~lO'`0rRyszP ̳O) 5	7G~9qKc,y/gDYnﳲM?yv'K<Ri3;ҙ\:&W:t) T;UÓ}p;k}J-̵He[-x_WTJf vCw{;@zثN31~CqH\xSs$8Tٚµk gy,0{i֍6eDSctҬ3WhqrwD+&Mޑ7si wOR' 6^YZ.;Moӭ4;,6S?!LsbLPs*cW:] \`M.	%Zw>{_}!&QZ k[z![V1 p'
w6=r7bfZYNV*~h*/-
|0&%C9.0M(dƩwWPaGFYjam&*gY==o=ILA
kGhz U0jcY4^0;a)`%Q!]+Se*)]sG|HB)A(oYV89_7}D8+ ~ˉ$H!G
;]&3_ !
sWg>r^>bJ1G (ѥu;cyR`OgJtמNa9M㷞v*'٤\4mw._ IzҥDSp7MfC7vg&S~;~y^h7HŚqݿu--^gi
j{JLZ<5=LL>S3Jt'?t8Mʺ޳^^4T(yjƤ|j=(T~jOE"J5k>ħ)g׶Z7dOsei=oiO=ҳof˹ϸ|%+Dv?0O Ͽ٨1D+A]-S`}aW4}EMH-ti=,O35u;yFSpf8cgAw۔w1 kwAt'VT!{ѧqgWU8yq]u0[jmD0UySi"1X/)dU!7A)ޒT;T.	Ȥ
tw-NW_.@PPq\Qi!%!YJWlm1*``<r`? R[T	P5D^&9|Dtŗx>khxя# 44!F۟uOZ9.%-1Z//ݼiB|0z\ɣI$z`ڈ?i\}"GYz(H	C<чTOF4zP7Ztc7QO..ai/fzc:ŢЋų4.yZ(a|_g:.g.Qۆ_&Q_?^l-E ĂxXSW $($Em-^%V/~LA?2Aw~\ɸo♴i 9fa||	XtѹX:6
~Ha9mV+>~ZѥM/S^GWiQ^w
5Ϣ^%iܪx۰Miѥz0]^RoyyK˝kaݯ{˽7vji_u^:vVxWc^Gk[ka_M?4'sh 0·u qonHRsy4Hxp7k䃺"Pܓ"OT<N;8<oߪyvexeU\^}A7#n\Ƹ%rb祥6R[_G8YEj5^"FKLN1q`EC?5(	.oEMq:v,O1SVb]{	ρwv]o ~j|]^ZUWR_.AC§m,lxMo`\tᤗNzg,F.]mEÿjaՌfZk!Kͯ|DKNkR!, ^Os%qFghOĺQyC)V w4N`Kt:Zbj )j<;G!l2YE93-Рv
X3 D/LNwY#
Mp<"Ճ/0ÃrP^}*Q	}(8yx? ˹.5mXXf3"gqyҊClZ;-,!/3B&qn}Lzna0%A{x]E 8a@%r݀\[)lG66Lz5I\"

ҡnS&>ŧ)nqڤAwe`
nR-Wox60Xjˡi`i!Q=-~|>?2KY0CZӼtU,l(4ьc~AQ[&\"UmkJ",]tR|w7l99nHhetYz9]LUER5Hj-*uCW_LVU	mT0$R_
p9^fPT]`7Z2JEQF/>r;!=Z/LrgOrضVsi%7ZX	."LX^+Tx(LH@2MxK6y5 ؑ;8B<6
WZ=IpJXTp%]{-?n|~<Zdvh4tD,nNnO.>,tؽD(n-WX%/jrH$Qn&oQ'^e-Y'w0ezeۯQ|+j+exj;@_m(?aqv'`0'Dr!G06Ƣ@Ko->%l6b/ w˫
-PAXV𢚡(
zlEъq~QGU0!z7 \ٖ)x
I=޻L<69%eLx	u<#'?nFd! !I32H4$;\}yl;mP & ..Qh|[QńzW];3z'*SOurIpv0oQ\Cwz=9zDi[⛟5bz.m#h/aWmF}@ȣÅN+u.;e7%c4wu
i2jF<Z8˽p4Jq.Ɓx(5ulgqSZV(j6S$s.כQk*+)P/g5HK (?n17Ԓ
?s{A7sB0TpT1r9zTΕ)"ٗX%</"8dmj^|,XKJkkW
50եxI̖nwpm7;vq[)润oQU x.RHUKUD`g`2ՋadjKe2WZ`$TjMMbNy+~[*ӢN FE*I,T*sܨis-']s(`[V tndkppFjl%mh5#R]o61Z>i;ynyVݑ(uQV59aVۇ(&?Svhq%5e1nnyi8ZaE.QMGH(#W$CiLf2jTCuԦ7j/P.*,K"'yD>Cu@6[m?M<즽sN=Q[60[U%Vs.k$ڒW8sT['3z*9-Z|dLm퓈v S	 }sAF i!wb
Xr%CXn@GQh0űS)<OOb7;:k(x[w?lf25sy PZSIݔrZRO<ق:],< 1aqsPTg&S}2$AhR֝_$jng.Rgh $OA(p\g!5\%ӰRDc|~}軖s'o'`f_$8FVQn2࿳j[.WFϹ[HrXk w=w%=OJ9XϺS6BῆFs3Y8˰gSKf(gsTĮO	*o[7݊Sn1TKU*K+tW)<z"yp#yG4S< &2ӥڭxpCNܶC'LID<M_b.Xs`bǢc[#tS 뢒_Ok+C"A>[P2Se!05K?Sa7~нF_twSԜЅN{PN5Ǫex1dOnGp˸C1ւjvpz3ŤcN?x!wȎՃ.4K./ ^YSߞp&q@߄btejX1k^ƃ|?4OsYZn޼Y%5GUoj{񟔵@j?3'`JWNS'رRs{!ycǲ~<]ɪ'	t*"ے(@m,**%hy$`,<Oj[7xD.c`KT)VPAa+o^V61zqZ:9A?:g4+
֙^v=gCf-AsgHqN:nu8Pc]bfR|F2J%6"_6)@m1'Zqԯ/0͙b`(%n_>`ҡ|Z$fئ7"Pϴ>n $	UeSUtt	0VOLU$e]OZ \S`iΑt)[ԘőR	;ݠrৰPs;@{m1 r,9QmQFf~|MLdc{j=},@vt5HVy1N1Rb[ZAB쵐jVn$|g?9IM$09[/!PptXu X R6O0CaZpT)׳nfתVb1<ix"B%0hd^uJ|3kC#2Dý6.m
aWUZ0yfM\A~AL1Bg=b96Gpنx&4!ϮowYh=̢dCGvHvE]9{FY.ɓGh$VjOP$ZR<Kܚ08v6 ë?㐐LpcJ51MNO_W"`bjc6d@S#2];B4Slda8Qs7Fcz|Ɨ1keI"^c>fW{A.qYvQ'/--n7x0fZuW{jQA3׆P ?WmHyuiNF/:J-˦F\RI6=3gl,*zwG
5p(ď	(0͖
eff)bQʊ3Tu]af^O&VYt*;rӴP_+69Y^b|ծ䡪dڊ&ˊL0GT"jtqUJ9e>i	tn:E5TTRͦ'~Jg}spns{25Qo*8s-v:6R9Ym`-IFCFdA/q Bsn5Ԅ!*[0
	"=rRZ՜gg_[ZJdUuJx),2uP'AAc(ryaBx!@ߣT$?|3wٯJSIe4LPc|aH2}蘤-2[XWly\]&%ǕVrS]AOǗݨ4ɠF\d#+iphwdIhI{	>Aǘ]|7=F.ÔNi4BeHhI)=-sb`Nv&C~WLsdE-0ǲr&#cT
w|"ɽ #^aTReBdsZ3z(A1'uq,9#>
;^I;tISA'sEw0>Kjٴ}LI锔=TݺHJ ̔XAV HJ渧=z$jβue3nٛSjBJ^Xhr5r^,ٛ,E /93Ԏ/Sic<ŗ\d:d^ʣǖ2]ݸJ.yw(#..Z=Q)]*bCeX^qU\uQ^[-4),u~.PȣJN;-<p>-<2tãHjxK14ZYZy^
U6S 3pv[QVAV>4OÛd%_TjՍO	nC8
Q~=x{ le͊~f@S)A2:?7ij1:)%C@WDVˆom3Jڪ~OuE@TB*j
>e3ԍ1afΔlc9'.XPJEZ]'i$tnBta&玛Es'-Rwmmλ嵯Z~|zem(^[}zu}믗;_~hգGz7/O%*)hM顨LK@LURCW)n_4Rگ挞ARi2
|j%vn4ɵb!`ϩJEUoA
}jdܴiwZHġVԟQ^`hAwiyaU4w>	oy<!\*@+0__t	s@l&-;JyS^t$	.a]X0h7F]&eٽ.|;:iꇖo_i+B 
&y&t]=^g]+u-$r݁|,*d*Y
}q2t?
	[/4`?h[6%Xk/I2!1]oB*eHQ_H>/zP&;ZRczΫE`[~DB?so0|BVӏ)B5ݽ\7
smTC(T˘5	rXo̹` /hreq ה9h̴լ`oV]֚U{p4cQKmng"e)Qxyvgo(U~Ds7/av_=}UH9o=9::xp5`q/z)4۳ckc뫨FPc;ibP،Y^[Z%-g2T~ӎ2DT+%^q}OR|M8VaoyUFRu;ϘOt'X]j8an>0˼9ymgd3xٗ=Ȇڶa5_kXM.0X9#geJMܾѦv:eXV͞> !֜YZnqhvne#Vt÷-+(D|(p|u}SyYzam_SQh`Pс^|صa8Gոl_bb;5*"i/>HVF8[0rfvAܛGw+I|b[x%O!H4_Poe\hO:À谷mZmK2*ޅ1uVsCwspQM]_k9*]U'G71fB;JcL4j-Bd,omMi[~2')97&6F>;j?7[ЯԒ]mMYNO#iTwaB9`V'Q S
=r7l蔴|&Nm/316NEŁ+8pVi4J ͙ ϢsP]	Ty㩃(ZwZ/^qD
)u
zNa?^`i/GqfD\NdP_9|m,0ܧ}5:IBIw&ch5嫊yg2켢CuZd QF(X.eEsM}fjX^1Әeѥ._ʉn.w;A|Er겸J	l-8DNYC<ٜ!JWT}sVa{huX0j.O1p<Y)\eI9+W ۢYvu҅Il|]xsuw,n&9j'e.MqI1O|Bʮ?!`իX~ }::oDzkYZ~g os<6e+Q\Te*eh9n	-(;IeCo4ʯw/M(G<?{ೊb}4úZ[;nT5ݭq#"'(ѝ,^X2zq],b<
^(/FVRyv5) 3P uqm0Jt?[0]_GG ɪͿzxՃ ` # a=AC$qU+Qܫ	 7c|a^\W]LJuq ^o,'缌0T3P hUOϯGEeUw|6nJWT?#B]
ߒb5P^~ר)r]ʾUSp:JT9B
I*UI81%JѽƖO'C,ao=dea5n^4msocHÑ#h9B=o3LjDsTr9+o&+->hCN.lz&ށ9JaKw#Nr)o$D1sq|Hk	Xq~)rL^ ,QewUTѽ3^
z8ǟ8)Y<ǩOr\Aˬ4	š)2d]FO4x340031Q02025054-IIM/J--OLI-MJLNKMI-LMaZ_f{&|^>_$z	H\f?ȳiՌ~RyQgILm{u6 yɉ%yH<h GG̽#<)djutusrR{	aTu1mw4^X<oaoj {d̶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%Ys1onM
	~?Khsz4[<-	s˝tZdQ*S4C|sBhh'G)LD(ϨtQ;T(*
{q  PK(nEb#4O\VzE.{g՛JxjvfH/@6yonUnW>^O*?/\[dx TԒ [>bUQ1	Ċ:lMpSDfnϛK䝏gϓzMǮW+ݛ=pAj32@(fqn7>lx3 T$.KATsֿ{ADx|#ȍd)bagq+hr Q*пޠ{u]f<Yqb	ësn_x3+:Z '&.<Oẙ
ʻ?9dp۵A^T/&%]trڕu蒉<;)-(C\d[jڏ80`#dJVgb7Dza/lqBTCu~p ^xL>:zʢp$kI	\OL¥=."P(D& yu>9::*);TG&Ƴr;'Ϋp.2!)drJ#񋢧 $]PBw{RB=Wp*p>5w8>j~7PV.tTzefk_+^4ir5#=JS3Ko?zk%!Rz~O,Vz-xvng4lhkm:93$Y>Aښ+_Ʃy7xcnlZf@K㘁v)54nZhۃ>\{D,7:>>1%֫C,K#s2!ːcO2EqHUaO<BzpHeTyGmG |J6lF$#"39)7T"AVDpq!#S~hD'08.J-;M7	-ss(Kʴu
+T}۾;A5+:"YnE7Kpf|#G;g0~y.6 $ܘ{DP75w^[h.Yyףқ7?x{<o0plпOgG}]MN!vlÐ0NtP݈N쑸0JuPTE""n%&wѥ6<Sqz{>X0L0o2nq 홰6w	û"34׸YIP'ly3t=EҶ@l3TxЈjh'$iXy^%흫qGAAq1bd@JKa4XYr+A4 ow!`glhup l0|C$0bv@hsG+f_rYj1]Q
q=-]:K]$U	-Z⾐ LBE wM`0老d:eсzN	9fJu$-58I$J%)"3B\7?i	i	i߉ϸgMPt/'!ݾ<`kuOqEnp"x[pDSR.z@tlhGEkb|~$]*uQVg7ކ4QU;W|@.|rß8V> h<'G9TZB,VJTjIJ2EyP</d˥;-WPSۄd0;dj8vX(iB2Zwڙt@)w|fIP7]*<zOVm HnE=@]F;*e5ܚv:G|%"M,W.3{H#$DԖ2SPGy`l"KÐ<kIMsǌH+y+`6CG2+rى׭>e I,J݂sRʮ\/MݿxVo6~_qh-	*Im0'^N=tHFRqawGҎl$<}K4Ѳ>u&uFzL+;Ukؔuhs>åV ̰{7`!}+]o@,:h]\Z\)w.`쀫!_ӤB'd?p]CS%)N^%Z$4uT|MѵM 2i6*x>;>!;}{'`+EE/uT}23va#7ZIgHYpt!-r̓1KR8:
!Z;4_>\UL
J.B.'ԽEٱ.ZU>'Ք,d TN	Esf6+nưPl&7,FSph*M:]Q
EͣWK vçZQ5:JN5:tiYoyԲe"3nkpp>g49I^W˲(T3v1'&s Ճ*Qҩ:(F?8s>;d7ʒu:Wh@^7غ5u\BR
GjHfӘ}*Q(zT9d*RuV{H/J)Y_\-U%SfT+o@XU^#Z2\Gq N߾}+QId]UDɪL<7}w]Yf%@>,WTݫ:Jf^bQ#thonxrW.%^Ȑ4\3OԚ)%Hn6}ޞ}_n2/^>?8LɈşϗ`G`n2;(oOn.ǳgzލLvN?.Wqd3gZ|v\bl̴Z@!2L4RL8{ƅŜh4~#(8*">+YjtKѦ5g!KI7$M~B^B}K6KOqy;|b)D-EA<});X(MC̨Bǭ]y/ix)b$D:lS<Ɉ..)LdtFТ/hȞ_x`m,9j)gtexz$tن145Z$߱QIv%Q(a?x
[Y}u3?Bo'=ǌ_MyIDMLcʒ/;ד0^ė(rQ*zFLũb
>5F=eAǴy/7ȧrREnN5%;*mIl>nDyli(lITQ-,7 F[F.G<T5ŢV_4޴<W_n3&Hm\UW\m8GgyG}j<A'77bˬ]T17V
8-8cz;bLvoGG@axT;$d0"=z5AR/. STHx
p# Design: Multi-Format Upload and Anti-Duplication Feature

This document describes the design for supporting all file types, media, documents, music, images, and other assets, alongside a robust anti-duplication feature based on SHA-256 (HTTP upload) and `telegram_file_unique_id` (Telegram bot upload).

## Requirements

1. **Anti-Duplication**:
   - HTTP Uploads: Compute SHA-256 hash of the uploaded file buffer. If a record with the same hash exists, return the existing file's download URL without uploading again.
   - Telegram Bot Uploads: Identify duplicates via `telegram_file_unique_id`. If the same file is forwarded/uploaded to the bot, return the existing download URL without forwarding again.
2. **Support All Formats & Media**:
   - Handle all media categories: `document`, `photo`, `video`, `audio`, `voice`, `animation`, `sticker`, `video_note`.
   - Fall back safely to `document` for any other unknown MIME types or formats.
   - Size limit for fallback/unknown formats must use the document limit (2GB).

## Architecture & Data Flow

### Database Schema Updates

We need to add a `file_hash` column to the `files` schema.

```typescript
// src/db/schema.ts
export const files = pgTable('files', {
  // ... existing fields ...
  telegramFileUniqueId: text('telegram_file_unique_id').unique().notNull(), // Add unique constraint if not present
  fileHash: text('file_hash'), // SHA-256 for HTTP uploads
  // ...
});
```

### HTTP Upload (Deduplication Flow)

1. Receive multipart form or JSON payload.
2. Generate SHA-256 hash from file buffer:
   ```typescript
   import { Bun } from 'bun';
   const hash = Bun.hash.sha256(fileBuffer).toString('hex'); // Or standard crypto
   ```
3. Check database: `select * from files where file_hash = hash`.
4. If record exists:
   - Return existing file details and download URL directly.
5. If record does not exist:
   - Run standard upload flow to Telegram storage.
   - Insert into DB including `fileHash`.

### Telegram Bot Upload (Deduplication Flow)

1. Receive update with message type (`document`, `photo`, `video`, `audio`, `voice`, `animation`, `sticker`, `video_note`).
2. Extract `file_unique_id`.
3. Check database: `select * from files where telegram_file_unique_id = uniqueId`.
4. If record exists:
   - Return existing download URL.
5. If record does not exist:
   - Forward to storage channel.
   - Insert into DB.

## Testing Strategy

- Unit tests for:
  - Hash generation.
  - Media type parsing (`getFileType`).
  - Size validation.
- Integration tests:
  - Uploading duplicate file via HTTP twice -> second upload returns first upload's `public_id`.
  - Bot receives duplicate `file_unique_id` -> returns existing link.
n	xHN# S3-Compatible TeleUploader + Web File Manager

**Date:** 2026-07-06
**Status:** Approved

Objective: Transform TeleUploader into an S3-compatible storage server (Telegram-backed) with a web file manager UI. Existing upload/download API paths remain unchanged.

---

## 1. Architecture Overview

```
S3 Client (aws-cli, rclone, s3cmd, MinIO Client)
         │
         ▼  AWS SigV4 + XML
┌─────────────────────────────────────┐
│          S3 Protocol Dispatcher     │  src/routes/s3.ts
│  - Parses path, query, auth headers │
│  - Routes to S3 operation handlers  │
├─────────────────────────────────────┤
│   JSON API v1 (for Web UI)          │  src/routes/web-api.ts
│  - Bucket CRUD                      │
│  - Object list/upload/copy/delete   │
├─────────────────────────────────────┤
│         Web File Manager            │  src/routes/home.ts + home.html
│  - Bucket browser, upload/download  │
├─────────────────────────────────────┤
│       Database Layer                │  src/db/buckets.ts
│  - buckets, files (extended),       │  src/db/multipart.ts
│  - multipart_uploads, parts         │
├─────────────────────────────────────┤
│       Telegram Storage Layer        │  src/utils/telegram.ts (existing)
│  - Single Telegram channel           │
│  - Files stored as Telegram docs    │
└─────────────────────────────────────┘
```

### Component Responsibilities

| Component | Responsibility |
|-----------|----------------|
| **S3 Protocol Dispatcher** | S3 fallback route (`$`). Parse Auth header, detect SigV4, route by method+path+query |
| **S3 Operations** | ~20 operations (ListBuckets, GetObject, PutObject, ListObjectsV2, MultipartUpload, DeleteObjects, etc) |
| **Auth (SigV4)** | Verify AWS SigV4 signatures, reject invalid/missing with 403 XML error |
| **XML Builder** | Template functions to construct S3 XML responses. Parse incoming XML (DeleteObjects body) |
| **JSON API v1** | JSON wrapper for web UI to avoid XML parsing in browser |
| **Web File Manager** | Single-page app served at `/` with bucket browser, upload, delete, search |
| **DB Layer** | New tables: buckets, multipart_uploads, multipart_parts. Extended files table |
| **Telegram Storage** | Existing forwardToStorage/getFileInfo; unchanged |

### Routing Priority

Route matching order (existing unchanged, S3 added as catch-all):

| Priority | Route | Handler |
|----------|-------|---------|
| 1 | `/api/upload` | Existing upload handler |
| 2 | `/f/:public_id` | Existing file redirect |
| 3 | `/file/:public_id/info` | Existing file info |
| 4 | `/health` | Existing health |
| 5 | `/docs` / `/swagger.json` | Existing swagger |
| 6 | `/` | Web file manager UI (NEW) |
| 7 | `/api/v1/*` | JSON API for Web UI (NEW) |
| 8 | `$` (catch-all) | S3 dispatcher (NEW) |

The catch-all route (`$` in Bun.serve()) inspects the `Authorization` header:
- Contains `AWS4-HMAC-SHA256` → handle as S3 request
- Otherwise → 404

---

## 2. Database Schema

### 2.1 buckets Table (NEW)

```sql
CREATE TABLE buckets (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT UNIQUE NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

-- Bucket name constraints: lowercase, no underscore, 3-63 chars (S3 spec)
```

### 2.2 files Table — Extended

New columns added alongside existing ones:

```sql
ALTER TABLE files ADD COLUMN bucket_id UUID REFERENCES buckets(id);
ALTER TABLE files ADD COLUMN s3_key TEXT;           -- e.g. "images/logo.png"
ALTER TABLE files ADD COLUMN storage_backend TEXT DEFAULT 'telegram';
ALTER TABLE files ADD COLUMN is_deleted BOOLEAN DEFAULT false;
ALTER TABLE files ADD COLUMN multipart_upload_id TEXT;

CREATE UNIQUE INDEX idx_files_bucket_key ON files(bucket_id, s3_key) WHERE is_deleted = false;
CREATE INDEX idx_files_bucket_prefix ON files(bucket_id, s3_key text_pattern_ops);
```

- `s3_key` is the full object key path
- `is_deleted` enables soft-delete for S3 DeleteObject
- `multipart_upload_id` links parts to their parent completed multipart object
- `public_id` (existing) remains primary identifier for Telegram redirect
- Existing non-S3 uploads have `bucket_id = NULL, s3_key = NULL`

### 2.3 multipart_uploads Table (NEW)

```sql
CREATE TABLE multipart_uploads (
  upload_id TEXT PRIMARY KEY,
  bucket_id UUID NOT NULL REFERENCES buckets(id),
  s3_key TEXT NOT NULL,
  initiated_at TIMESTAMPTZ DEFAULT now(),
  status TEXT DEFAULT 'in_progress' CHECK (status IN ('in_progress', 'completed', 'aborted')),
  initiated_by TEXT
);
```

### 2.4 multipart_parts Table (NEW)

```sql
CREATE TABLE multipart_parts (
  id SERIAL PRIMARY KEY,
  upload_id TEXT NOT NULL REFERENCES multipart_uploads(upload_id) ON DELETE CASCADE,
  part_number INT NOT NULL CHECK (part_number BETWEEN 1 AND 10000),
  telegram_file_id TEXT NOT NULL,
  telegram_file_unique_id TEXT NOT NULL,
  storage_message_id BIGINT NOT NULL,
  size_bytes BIGINT NOT NULL,
  etag TEXT NOT NULL,                     -- SHA256 hash as etag
  created_at TIMESTAMPTZ DEFAULT now(),
  UNIQUE(upload_id, part_number)
);

CREATE INDEX idx_multipart_parts_upload ON multipart_parts(upload_id, part_number);
```

### 2.5 Multipart Storage Strategy

Each part is stored as an independent file in the Telegram channel. The DB links them logically:

- **UploadPart**: forward part to Telegram via forwardToStorage → record in multipart_parts
- **CompleteMultipartUpload**: create a single `files` row with `multipart_upload_id` pointing to the parts. No new Telegram upload needed.
- **GetObject for multipart object**: query parts ordered by part_number, stream sequentially via Telegram CDN URLs. Client sees a single continuous download.
- **AbortMultipartUpload**: delete DB records only (Telegram orphan files are accepted as GC is not feasible)

---

## 3. S3 Protocol Layer

### 3.1 Full Endpoint Coverage (~20 endpoints)

#### Bucket Operations

| Method | Path | Query Params | Handler Description |
|--------|------|-------------|-------------------|
| GET | `/` | — | List all buckets → `<ListAllMyBucketsResult>` XML |
| PUT | `/{bucket}` | — | Create bucket (reject if exists) → 200 |
| HEAD | `/{bucket}` | — | Check bucket exists → 200 or 404 |
| DELETE | `/{bucket}` | — | Delete empty bucket → 204 |

#### Object Operations

| Method | Path | Query / Special | Handler Description |
|--------|------|----------------|-------------------|
| GET | `/{bucket}/{key+}` | — | GetObject: redirect to Telegram CDN (same as existing `/f/:public_id`) |
| HEAD | `/{bucket}/{key+}` | — | Return metadata headers (Content-Length, Content-Type, ETag, Last-Modified) |
| PUT | `/{bucket}/{key+}` | `x-amz-copy-source`? | Without copy header: upload multipart form → Telegram. With copy header: copy existing object in DB |
| DELETE | `/{bucket}/{key+}` | — | Soft-delete (is_deleted = true) → 204 |
| POST | `/{bucket}/{key+}` | `?tagging` | Return 204 (no-op, tagging not implemented) |

#### Object Listing

| Method | Path | Query Params | Handler Description |
|--------|------|-------------|-------------------|
| GET | `/{bucket}` | — | ListObjects v1 → `<ListBucketResult>` XML |
| GET | `/{bucket}` | `?list-type=2` | ListObjectsV2 → `<ListBucketResultV2>` XML |

Supports: `prefix`, `delimiter`, `max-keys` (default 1000), `continuation-token` (v2), `marker` (v1), `encoding-type=url`.

#### Batch Operations

| Method | Path | Query Params | Handler Description |
|--------|------|-------------|-------------------|
| POST | `/{bucket}` | `?delete` | Parse XML body `<Delete><Object><Key>...</Key></Object></Delete>` → soft-delete each → `<DeleteResult>` XML |

#### Multipart Upload

| Method | Path | Query Params | Handler Description |
|--------|------|-------------|-------------------|
| POST | `/{bucket}/{key+}` | `?uploads` | Initiate: create multipart_uploads row → `<InitiateMultipartUploadResult>` XML with UploadId |
| PUT | `/{bucket}/{key+}` | `?partNumber=N&uploadId=X` | Upload part: stream to Telegram → record in multipart_parts → return ETag header |
| POST | `/{bucket}/{key+}` | `?uploadId=X` | Complete: parse `<CompleteMultipartUpload><Part><PartNumber>N<ETag>...</Part></CompleteMultipartUpload>` → create files row with multipart_upload_id → `<CompleteMultipartUploadResult>` XML |
| DELETE | `/{bucket}/{key+}` | `?uploadId=X` | Abort: delete records, update status → 204 |
| GET | `/{bucket}/{key+}` | `?uploadId=X` | List parts → `<ListPartsResult>` XML |

### 3.2 AWS Signature V4 (SigV4)

Authentication process for each S3 request:

```
1. Extract Authorization header
2. Parse credential scope (date/region/service)
3. Reconstruct CanonicalRequest
4. Compute expected signing key
5. Compare signatures
6. Match → proceed; Mismatch → 403 Forbidden with AWS XML error
```

**Canonical Request construction:**
```
<HTTPMethod>\n
<CanonicalURI>\n
<CanonicalQueryString>\n
<CanonicalHeaders>\n
<SignedHeaders>\n
<HashedPayload>
```

**Implementation notes:**
- Only verify signature; do not validate timestamp freshness (simplified, acceptable for single-server setup behind reverse proxy)
- Hard-code `region = 'us-east-1'` (irrelevant for functionality; S3 clients accept any region)
- Single set of S3 credentials (`S3_ACCESS_KEY` + `S3_SECRET_KEY` in env)
- Chunked transfer encoding (`aws-chunked`) — not supported initially; client must use standard PUT. Return 501 if detected.

### 3.3 XML Serialization

No external XML library. Template strings for responses:

```typescript
// Example: ListBuckets response
const listBucketsXml = (buckets: Bucket[]) => `<?xml version="1.0" encoding="UTF-8"?>
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <Buckets>
    ${buckets.map(b => `<Bucket>
      <Name>${escapeXml(b.name)}</Name>
      <CreationDate>${b.createdAt.toISOString()}</CreationDate>
    </Bucket>`).join('')}
  </Buckets>
</ListAllMyBucketsResult>`;
```

**XML parsing** (only for DeleteObjects body): use simple string matching / regex on `<Key>...</Key>` tags. The DeleteObjects XML is simple and well-structured enough for this without a parser library.

### 3.4 Error Responses

All S3 errors return XML with HTTP status:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Error>
  <Code>NoSuchKey</Code>
  <Message>The specified key does not exist.</Message>
  <Resource>/bucket/key</Resource>
  <RequestId>...</RequestId>
</Error>
```

Common error codes: `NoSuchBucket`, `NoSuchKey`, `BucketAlreadyExists`, `BucketNotEmpty`, `InvalidPartOrder`, `NoSuchUpload`, `SignatureDoesNotMatch`, `AccessDenied`, `InternalError`.

### 3.5 Presigned URL Support

`GET /{bucket}/{key+}?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-SignedHeaders=host&X-Amz-Expires=3600&X-Amz-Signature=...`

For presigned GET URLs:
- Verify signature (much simpler — no body hash required, querystring-based)
- If valid → return redirect to Telegram CDN (same as GetObject)
- If expired → 403

Only presigned GET is essential; presigned PUT is optional for v1.

---

## 4. Web File Manager UI

### 4.1 Served At

Route `'/'` → `home.html` (single HTML file with embedded CSS + JS)

### 4.2 Layout (responsive)

```
┌─────────────────────────────────────────┐
│ ☰ TeleUploader    ◉ my-bucket ▼ [+]     │  ← Top bar
├─────────────────────────────────────────┤
│ ─────────────────────────────────────── │
│ 🗂  images/            Jul 06   2 items  │
│ 🗂  documents/         Jul 05   5 items  │
│ 📄 logo.png    2.4 MB Jul 06  [⋮ ▼]     │  ← Actions dropdown
│ 📄 report.pdf  1.2 MB Jul 05  [⋮ ▼]     │
│ 📄 photo.jpg   3.1 MB Jul 04  [⋮ ▼]     │
│ ─────────────────────────────────────── │
│                              ↑ Load more │
└─────────────────────────────────────────┘
│ Drag & drop upload area (footer)        │
└─────────────────────────────────────────┘
```

### 4.3 Features

| Feature | Implementation |
|---------|----------------|
| **Bucket selector** | Dropdown, fetches bucket list from `/api/v1/buckets` |
| **Create bucket** | Prompt for name → POST `/api/v1/buckets` |
| **Delete bucket** | Confirm → DELETE `/api/v1/buckets/{name}` |
| **Object listing** | GET `/api/v1/buckets/{name}/objects?prefix=X&delimiter=/` |
| **Folder navigation** | Breadcrumb from prefix, click to drill down |
| **Upload** | Drag-drop or click → POST `/api/v1/buckets/{name}/upload` |
| **Upload progress** | XMLHttpRequest.upload.onprogress |
| **Download** | Direct download via GET `/api/v1/buckets/{name}/download/{key}` |
| **Copy link** | Copy full S3 URL to clipboard |
| **Delete** | Confirm → soft delete |
| **Search** | Filter by key prefix (debounced) |
| **Credentials info** | Show S3_ACCESS_KEY + S3_SECRET_KEY (from env) in a modal |
| **Dark/light mode** | CSS variables, @media prefers-color-scheme |

### 4.4 Web API v1 (JSON)

These endpoints enable the UI without XML:

| Method | Path | Response |
|--------|------|----------|
| GET | `/api/v1/buckets` | `{buckets: [{id, name, createdAt, objectCount}]}` |
| POST | `/api/v1/buckets` | `{id, name}` |
| DELETE | `/api/v1/buckets/{name}` | `{success: true}` |
| GET | `/api/v1/buckets/{name}/objects?prefix=&delimiter=/&continuationToken=` | `{objects, prefixes, isTruncated}` |
| POST | `/api/v1/buckets/{name}/upload` | Multipart form → `{key, size, etag}` |
| DELETE | `/api/v1/buckets/{name}/{key+}` | `{success: true}` |
| POST | `/api/v1/buckets/{name}/copy` | `{sourceKey, destKey}` |

### 4.5 Download via Web UI

For the web UI, download doesn't use S3 headers. Instead:
- `GET /api/v1/buckets/{name}/download/{key+}` → redirects to Telegram CDN, similar to `/f/:public_id`
- This bypasses S3 auth (which the browser can't do with SigV4)

---

## 5. Existing Routes — Unchanged

All existing functionality remains intact:

- `POST /api/upload` — multipart + JSON file upload
- `GET /f/:public_id` — file redirect to Telegram CDN
- `GET /file/:public_id/info` — file metadata
- `GET /health` — database health
- `GET /docs` + `/swagger.json` — API docs

The existing upload flow does not interact with S3 bucket/keys. This is intentional: the JSON API remains for simple programmatic upload without S3 complexity.

---

## 6. Security Model

| Layer | Mechanism |
|-------|-----------|
| **S3 API** | SigV4 signature verification. Single credential pair from env |
| **Web UI** | No auth (internal tool). Relies on network-level security / reverse proxy |
| **JSON API** (existing) | Rate-limited only (same as now). No additional auth |
| **Rate limiting** | Applied to S3 operations per IP |

---

## 7. Implementation Order

The following build sequence minimizes blocked dependencies:

| Phase | Tasks | Depends On |
|-------|-------|------------|
| **1. DB schema** | Create `buckets`, `multipart_uploads`, `multipart_parts` tables. Migrate existing DB | Nothing |
| **2. DB layer** | CRUD functions for buckets, multipart, files extended | Phase 1 |
| **3. S3 Auth + XML** | SigV4 verification, XML builder templates | Nothing (parallel with 1) |
| **4. S3 Bucket Ops** | ListBuckets, CreateBucket, HeadBucket, DeleteBucket | Phase 1, 3 |
| **5. S3 Object Ops** | PutObject, GetObject, HeadObject, DeleteObject, CopyObject, DeleteObjects | Phase 2, 3, 4 |
| **6. S3 Listing** | ListObjects, ListObjectsV2 | Phase 2, 3, 4 |
| **7. S3 Multipart** | CreateMultipartUpload, UploadPart, Complete, Abort, ListParts | Phase 2, 3, 4 |
| **8. S3 Dispatcher** | Wire up catch-all route with auth detection | Phase 4-7 |
| **9. JSON Web API** | All `/api/v1/*` endpoints | Phase 2, 4 |
| **10. Web UI** | home.html with bucket browser, upload, delete, search | Phase 9 |
| **11. Tests** | S3 auth test, bucket operations test, object operations test, multipart test | Phase 4-8 |

---

## 8. Configuration (New Env Vars)

```env
# S3-compatible API credentials
S3_ACCESS_KEY=teleuploader-admin       # Access key for SigV4 auth
S3_SECRET_KEY=your-secret-key-here     # Secret key for SigV4 auth
S3_DEFAULT_REGION=us-east-1            # S3 region (cosmetic, affects signature scope)

# Existing vars unchanged:
# BOT_TOKEN, ADDITIONAL_BOT_TOKENS, STORAGE_CHANNEL_ID, BASE_URL, ...
```

---

## 9. Testing Strategy

| Test Focus | Scope | 
|------------|-------|
| SigV4 auth | Verify signature verification, reject invalid signatures |
| S3 Bucket operations | Create, list, head, delete buckets |
| S3 Object operations | Put, get, head, delete objects (via Telegram) |
| S3 ListObjects | Pagination, prefix, delimiter, continuation token |
| S3 Multipart | Create, upload parts, complete, abort |
| S3 DeleteObjects | Batch delete XML body parsing |
| S3 Error responses | Correct XML error format per status code |
| Web API | JSON endpoints return correct data |
| Non-S3 routes | Existing routes still work (regression) |

---

## 10. Open Questions / Future

1. **Presigned URL support** — GET presigned URLs in v1; PUT presigned for v2
2. **CORS headers** — if web UI served from different origin than S3 API calls
3. **Versioning** — not supported (single version per key)
4. **ACL / Bucket policies** — not supported (single user model)
5. **Lifecycle rules** — not supported
6. **Website hosting** — not supported
7. **Default bucket** — consider creating a default bucket on first start for convenience
8. **Upload via S3 API directly to web UI** — web UI could also call S3 API directly for maximum compatibility demo
9. **S3-compatible client list** — tested clients: aws-cli, rclone, s3cmd, MinIO Client, Cyberduck
 	.gxTKs0+4ref!uKtdk1[R%d+eڏBCN*.@	,فLp(+&M@~ZYHVPF-pH_ST%4D=zߝd5GIiSxÖvې4f̲qe-)&6ܠS9Ҋ&|iCEڒ_KlOs;hV{D?RG*f;CO-܍pk݌&u7Ru7Nn,=}eBm'E0IHW%	v{C"!ݝ%?	j.9QR.5~YMx?
P|ޡFJS}{+Iڭ<gpZb")q_ChN{	Og.py=8q/u',|GAMB	y{1<2Y+3Mh{.DmIkv~3{vu̶̄M6á);ޣ&05xVM0W ڕzYJHK=Y&8|`v0P$gئxt |liL
- _<{ix3||2;QCtx73O{Nk98pd$&'H$k8)Q,3h]%!E!ιR4	MbC
O7<[G$=.u8fOr6>7:7$eia*ya3!̀o/7_#e8fD,¬tn|iFsvV遂j["^ZTA(!Ué85(ƙצXmcrFXQ'[hVZSڇm_+GFH.hE,,Ǉ,2̕,^6PM fBœvn?/Ne<ԚK@N/x\h
qKʘΙ*Vø%&nՈ[ɠ?^4Xlq$Y3Febp,{5:
_tnKj]5f^`k~Ӕ2:sj.5vXen^klгA>:k*Nט4,x~
LBS柽v3|n&WIut2(zQwcԧx 100644 bot.ts :5ze840000 db _~]_Jۅ:100644 env.ts ?Ԝy4iғ4100644 home.html z2EA^100644 index.ts me(3qM3k40000 routes Xˀrg''40000 utils -YQ*PnFlpx8W_x]%import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { type Context, Telegraf } from 'telegraf';
import { db, files as fileSchema } from './db';
import { config } from './env';
import logger from './utils/logger';
import { forwardToStorage } from './utils/telegram';

interface MediaGroupBufferItem {
  ctx: any;
  fileId: string;
  fileSize: number;
  mimeType: string;
  fileName: string;
  fileType: string;
  fileUniqueId: string;
}

const mediaGroupCache = new Map<string, { timer: any; items: MediaGroupBufferItem[] }>();

export const startBot = async (): Promise<Telegraf<Context>> => {
  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.`,
      );
    });

    // Cast bot.on elements individually or explicitly as any to bypass Telegraf v4 typescript deprecation warnings on array syntax
    (bot as any).on(
      ['document', 'photo', 'video', 'audio', 'voice', 'animation', 'sticker', 'video_note'],
      async (ctx: any) => {
        try {
          const fileType:
            | 'document'
            | 'photo'
            | 'video'
            | 'audio'
            | 'voice'
            | 'animation'
            | 'sticker'
            | 'video_note' = ctx.message.document
            ? 'document'
            : ctx.message.photo
              ? 'photo'
              : ctx.message.video
                ? 'video'
                : ctx.message.audio
                  ? 'audio'
                  : ctx.message.voice
                    ? 'voice'
                    : ctx.message.animation
                      ? 'animation'
                      : ctx.message.sticker
                        ? 'sticker'
                        : ctx.message.video_note
                          ? 'video_note'
                          : 'document';

          const fileObj =
            fileType === 'photo'
              ? ctx.message.photo.slice(-1)[0]
              : fileType === 'sticker'
                ? ctx.message.sticker
                : ctx.message[fileType];
          const { file_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 mediaGroupId = ctx.message.media_group_id;

          if (mediaGroupId) {
            if (!mediaGroupCache.has(mediaGroupId)) {
              mediaGroupCache.set(mediaGroupId, { timer: null, items: [] });
            }

            const group = mediaGroupCache.get(mediaGroupId)!;

            if (group.timer) {
              clearTimeout(group.timer);
            }

            group.items.push({
              ctx,
              fileId: file_id,
              fileSize: file_size,
              mimeType: mime_type,
              fileName: fileName,
              fileType: fileType,
              fileUniqueId: fileObj.file_unique_id,
            });

            group.timer = setTimeout(async () => {
              mediaGroupCache.delete(mediaGroupId);

              try {
                const itemsToUpload: MediaGroupBufferItem[] = [];
                const responses: string[] = [];

                for (const item of group.items) {
                  const existing = await db
                    .select()
                    .from(fileSchema)
                    .where(eq(fileSchema.telegramFileUniqueId, item.fileUniqueId))
                    .limit(1);

                  if (existing && existing.length > 0) {
                    const url = `${config.baseUrl}/f/${existing[0].publicId}`;
                    responses.push(`File *${item.fileName}* sudah diupload! 📎\nDownload: ${url}`);
                  } else {
                    itemsToUpload.push(item);
                  }
                }

                if (itemsToUpload.length > 0) {
                  const uploadItems = itemsToUpload.map((item) => ({
                    fileId: item.fileId,
                    fileName: item.fileName,
                    fileType: item.fileType,
                  }));

                  const { forwardMediaGroupToStorage } = await import('./utils/telegram');
                  const batchResult = await forwardMediaGroupToStorage(uploadItems);

                  const dbInserts = [];
                  for (let i = 0; i < itemsToUpload.length; i++) {
                    const item = itemsToUpload[i];
                    const publicId = nanoid();
                    const uploaded = {
                      publicId,
                      telegramFileId: batchResult.telegramFileIds[i],
                      telegramFileUniqueId: batchResult.telegramFileUniqueIds[i],
                      storageChatId: config.storageChatId,
                      storageMessageId: batchResult.storageMessageId,
                      fileName: item.fileName,
                      mimeType: item.mimeType || 'application/octet-stream',
                      sizeBytes: item.fileSize,
                      fileType: item.fileType,
                      uploaderId: ctx.from.id,
                      createdAt: new Date(),
                      updatedAt: new Date(),
                    };

                    dbInserts.push(uploaded);
                    responses.push(
                      `File *${item.fileName}* berhasil diupload! 📎\nDownload: ${config.baseUrl}/f/${publicId}`,
                    );
                  }

                  if (dbInserts.length > 0) {
                    await db.insert(fileSchema).values(dbInserts);
                  }
                }

                await ctx.reply(responses.join('\n\n'));
                logger.info('Media group uploaded as batch', {
                  mediaGroupId,
                  totalFiles: group.items.length,
                  uploadedFiles: itemsToUpload.length,
                  uploader: ctx.from.id,
                });
              } catch (error: any) {
                logger.error('Failed to process media group batch', {
                  error: error.message,
                  mediaGroupId,
                });
                await ctx.reply('❌ Gagal mengupload beberapa file di media group.');
              }
            }, 600);

            return;
          }

          const existing = await db
            .select()
            .from(fileSchema)
            .where(eq(fileSchema.telegramFileUniqueId, fileObj.file_unique_id))
            .limit(1);

          if (existing && existing.length > 0) {
            const url = `${config.baseUrl}/f/${existing[0].publicId}`;
            await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, {
              reply_parameters: { message_id: ctx.message.message_id },
            });
            logger.info('Duplicate file detected in bot, returned existing link', {
              publicId: existing[0].publicId,
              fileType,
              fileName,
              uploader: ctx.from.id,
            });
            return;
          }

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

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

          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: any) {
          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 as any).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: any) {
    logger.error('Failed to start bot', { error: error.message });
    throw error;
  }
};
#~VRkx,J#"%findFileByUniqueId } from './db/files*{
  detectFileType,
  extractFileFromMessage,
  getErrorMessage,
  getFileSizeLimit,
  type TelegramMediaMessage,
} from './utils/file^type BotContext = {
  message: TelegramMediaMessage;
  from: { id: number };
  chat?: { id: number };
  reply: (text: string, extra?: {!!number } }) => Promise<unknown>;
};

type MediaEventRegistrar = {
  on: (events: string[], handler: (ctx: BotContext) => Promise<unknown>) => void;
};

const replyWithDownloadUrl = async (ctx: BotContext, publicId: string): Promise<void> => {
  const urlL!(|!J!<});
}#YLogging middleware must be registered BEFORE the media handler so all events are captured#[2'type' in ctx.update ? ctx.update.type : undefined$MDconst mediaBot = bot as unknown as MediaEventRegistrar;
    mediaBotEs- = detectFileType(ctx.message);'Obj = extractFileFromMessage(ctx.messag	{ file_id-Size = fileObj.file_size || 0	81 getFileSizeLimit(fileType);

          if (fileSb$existing = await findFileByUniqueId(.A) {
            await replyWithDownloadUrl(ctx, existing.publicIdPwSizq"} ,await replyWithDownloadUrl(ctx, publicId);

."unknown"60
            error: getErrorMessage(error),
    $%|!W#Oa$unknown$5getErrorMessage(error)@%.x	100644 buckets.ts T}f9+PֲɈDF100644 files-ext.ts A#*O8_L100644 files.ts jr#}'I{100644 index.ts vQfzZx.v100644 migrate.ts e<'h-O]jZA100644 multipart.ts 8v\fJFY69100644 schema.ts :b%
/O'f仟xݔao0+nҤRJnhtn&90P9N eFۭ%r|w{c[LҰk+h{ww!GjW-<%3*B8h	iXHj}F" DpVBTO	}\cpf*V^L>&^4xG*8!2zqjzR?6ϴրSȎ1۴:i[voDC9`n:ׅfۆaHl7aziW<{嵚d[95[4ຜ{C2mλ7BՍ2+we"
udډEDI52Sӈh;Z'qrLFْ{%ƶJVۈX7I>aǽpOe^ÚwxFx'jbUU	Q1j58d94L7nd )m%-	`fp§q?o0BrM Ny :6Ga ɭ*>|D! PρЮkyMl1J'4x0 -pܪ
ت͋~us'E\Sh$B*vEU~l콭W}}Hyk?xW[o6~ϯ8:Xj<EޜCҸk0CKtE8oKK0"}~8]L3Ї.,H`ЉXrvb+ͻʁp1	4%%,g}tYQ}AъW(ڣJ,eRH1s}4?̋Ө\8;=~nƶ$2$xsGY0@6Y^k%kw%*sI)"I0*Q"zMb N
+Ϲ쮗QO}OE:vKni	j'(.Hi)h>zIK3*
c{7C;]՞Hd>|r`b9 >'X(,_gCtp-ƱgFB dXy	TcLzU?-B@E$",=ȊtNYqF9ǟTo7$Z^iBi)֥*ӗzOX4rs<.gsn:cU2k]eqKhOK.!Y{&\&/X4G`]4oBm!otC=JwT.tUu0Ǟ\8	2dU0׻>Աhg^K<J2SaJվܞtmϝQG]	.fFDEW[Q#B[sq1'?\2_n+%VAOY׶<b0\r.9Z4W&3Zzo94ؖ#Kx)>O=|_ǟ_Ggk/
;:?JzĩmqO}ޢ1s9+}3Z{Vmod%0hx1>/QFs 뱽bxG9eʘ*?ޘUub<tn\` kB;kB2mP=uA-G<Q7gא/ٞl)U@qXd4!ەWs-ifwbב<Qs* G&0@lys2۪N^@y>UDQRaۚ-PYo")vrI%fkR2rQicdɥ~yFz[UzIfvM[Bɢ^eiwD7-o=}\]MGNFzonJ-Gӫxپ6ךO할㠫{dUc"s^%;AjGϪHqNI+G:{|CQdu=h_i`}S3sȷ琙X
9g$ι2ͫ>5xnoZL'yɗq}3|/yew/ѿxۥKis|7/r`Ǐ$"hͥPZRZKsJ&L^/ ZYlP\R
\	$=~IE7
p2 h%5xRimport { eq } from 'drizzle-orm';
import { db, files as fileSchema } from './index';
import type { File } from './schema';

export const findFileByHash = async (hash: string): Promise<File | null> => {
  const result = await db.select().from(fileSchema).where(eq(fileSchema.fileHash, hash)).limit(1);
  return result[0] || null;
};

export const findFileByPublicId = async (publicId: string): Promise<File | null> => {
  const result = await db
    .select()
    .from(fileSchema)
    .where(eq(fileSchema.publicId, publicId))
    .limit(1);
  return result[0] || null;
};

export const findFileByUniqueId = async (telegramFileUniqueId: string): Promise<File | null> => {
  const result = await db
    .select()
    .from(fileSchema)
    .where(eq(fileSchema.telegramFileUniqueId, telegramFileUniqueId))
    .limit(1);
  return result[0] || null;
};
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)ndxTn0}WOIJ먯Y@*Qn[B7d&vv6%d7-H<9sf<n(cK
jpLB7S@Tk^ZQn*Uw1>J 98 >4ǷPRs+Y&[a͙Q1\*xUAVV4DM~.l,XZp޽ӯg#I\ՑZcA|9ps'3>7J'0?  ILRfݐral
^-rM,ĺdbT3aIrVꯙFgItD}xLAI!7
C(𰯪7x1hWD95]"
Փ5Q+!_cNCPiJi@%<li\X(7ULޏNdoĳ)Q~˅3( =NcR#/]~'{	cjnnlɵI9DtHo+%dDD4Ze4JΐcѶZ+W6/F_Kni|1tq55ZihD[\!*q s_}Gl.8RhTi.ag/zW/h}
I;k_(:(aVw)\߻9@sw{<2i]|gb1].|Tx *CBGraxVMo8W̡%@U-$m>8-A%"Q.I5q\C2)Z6{bj8$߼#)%#LYY@/c5oKVq&kђdk) T`6E)*d%`y Pev\0Bg}iTm?P"8;p.K QN3gj[RK% Z[*kz
CqߝGsQ24Wsw:W|F, eMԆl1\FdmӒe&To\>P]ZR.# M!Hg3Stí,*q'9ɉ<Δ]pW`gDX U/.G7P4f#Iհ" ǓE_O?'wY68VY69*daQ1D^*5d
cj@saRDTC\uc2L~tB8>oGxʙ{PT:U/U;1`0[1ѷ$ȃ%,5g[t[nxMTW>stQqb_&۞mSuz=e\uTZZ;}^K秣&I],=Mǡa!(	dRSTd6C%c'V-_nUc)xr~I蝴z\tԹmld$ZKx*PO-Gb*#&P6a)PQ?ەyq5kmkkKvl;˵3ӈ5)od1'CZ߻"U<Sd+[͓
S=fxo^QwvW%
4*nDÐ>7bl7nxbx3i>LbO<Gu`iȚOV%O#LCx[!Db+6L}Ld24RKsJ&`c6U,z()ǓZRZ = ׿gxUMs0W&<CCnNۙm}h3#`5D
P0nKҖئ'˲ ,hR9#  9B+Ihn'JiIogI71IʘT'
7-`,ULSrU4ZLƒ0S'1U(\2&_PZ؟̕1Wu*xD+ܺj@^3,a|K^&.O?znuq*6eˬ
G'⎅e [ e
ô
ϔ\k&KͣD`Į?u;ɕྱ`JR]RxSMu%qڃ*ttirnt~~uK>IV7SQ;a3G۝JZg/`s_EsϿ{ejz{cxW<[pkIr	WLvĆ{C⇯>P.#0;\V]UW|k+y;|tFUe!+Y g<txVmS8_!f	Nybkb%8v*)@﷒DNwkw#2'(N1E̐fUD.jǕ
9`=xB詂M½u8%ւ0$$q53+WSܺx'x1TnG4R%"g52NB$FdFT<f`t9Mu;p<Jz2^PX^+3\r\0V	4pefGx;K8v1|k'"C<S)tQbTRqD!Bq}Eý{3_"jSo}}wEmjڮ㏆lUxm.R-|0bŬcjp98AO0ӕ@&$o Zk5>Q@8=?+˚&gb2?KgX}D5Cv8q4BPI<|a7JyE-MP#a0h)GBb4廡0aP3]֙=`IE7.RPK~wbB^jAM^8w%	zawr8"#r9VN^әf%zbA搄8[8]6v<inAL72KN̈vRn%J$o`ώt1hqf9~o4wRo1k&x,6P^7R,h^~Wׯ1{'U \Z=C'-QRXM]QƆ-gmw_ҫÖk#8]5mq[1#nX6eMI|mJT%KS#1TpVG?̂ʪ!u8]CI"KW-N~{p\(䙶\Z?GNv5\?^f,k)vwvop[&-;5>;#pX++p;9~s C	[xX'\e}XV½8Vq]̹8i15s󩳁:27FLdr
Z->? Kp3|@8ut012355L},-QNT6,+]0zܭBOf/̵&kU|n'WQ2:
/!Tx[_sHrO1yDdimROv˒\ %sJRWuϩ{[|||tRo]WJ3==</<[_,m_0vgޜ'˗ǭCmHqGIb^f"/n'a2A7x ܁We2R}D	b{C>YOuiRO(ؚv'!O31ǟQ1
)`E{MG'bRAgNhx}LqQZ_rfǉ$zQ%X!yr.Uץ`{'#c\.ɓGODE_1?O7`bk6>vSME |Of22Pb}ó8U!,;8tfba_s_B.,V&QuXôD"Lw=KeOl\{Hyڀ΅A$b!ò߿dQ<I!/8 4 >,J`u,f:ʭFv42~.Ue4
wE8J"X4jĀkZ2!;7I,ե:\XjCSpg),հS!)N`]!=w2GVmZuC[&)#IK֠%'L|EYyUim$PWx)zԟ4\5f;xs xbUxt@ Gn",mqѪށ8\	rxd{, (ΣZ$/J6ДHVaЖKd䤠$_3멦%WJ u=퀶-1!bTbDw5M	#scb:6K6/B=m,@ƩLt%0Mx@ :	iC$:r^K>GD.e
3̊,1F)'j4N4Ⱦbk$N~߅a6aXj̧(*/T~>ʫ0ӌLr@\o{[>(e>́PO',iJf[yҲCC&w96G4KȘ֔	2F
q<,;bz1.L!Ǽd+Ij>ad<@s''ůX-'c#`Gi`"aìH/lNwbc~n^K0QUU<NJW2?gZQ`G]hkM)?1׮I*9*iT>16>u*HD}&"qZT,SEnGijFt	묧lgX
ږ/o4u[jR3j_p[?Y6iSޟ6LnkEvA-5[ysjgs:7<X&	D1B%f?yAYOuӄ{~VD]ytvAPt_@}'nz`c13oM-uk\Q.|@|mh<
`1KM1ɏi	ز'U~d2DXl[9fLܘsSFQbR
6Ť6ve~4Vs;"6Ҍ'g<RQ&тes0qې	U@P2)zxBܳlhP˿Ї) h[hPQDAJXxTx\|`kN*!M3Tފ|ָ1%%5D/ {S n0/H<S1J:) [>z:i練X] &A
ݘetַjYEۛI)4~Q<ԄyBK Q4T#%tz̎9 Dqt	\
	Ƙ"ːSf?Nz$Uxgf"I0b l^aMh69dg?TQ]ew5q
a`	Skf霮Po#t,Q %{Z#*j.V7ClOAOjj͆a&8]PU0"o@k~|<_}ۖ4`#P$\~v).	}UzB%Mk\<2#rg>M YmY8c'ySQFHuuE6dۛܩzy&4\<YmPOȻJ7A*cĝsY Y]{OF`/Ա2iLmͶ	5{I'^?2د`i6&NU}4vEˁ!2L)Gϣ(<,^T\ǐH2BCFgWta\,Wa
Hoܲh&0!GU=n{=?\$6JRJdh^@Sj>ν$)c6cV|/0 Ú̗U$X&Hz""_h%Y7=SEm59E `ZR/-KV퉡rĞLbfMΆ̌z{%2/5ڔT.80JMSQjJSBc3(Y@gbSo}WY߁zqRyrI',3uYԖYb\y@'+,6gtaj)$I6N߸+Iz0#aam.S*x'CV0Sipm0뀉'9"ȰFi06}͢6SOʏڔ+ jhk1` 3F}ckl$^ldW](|sxQrr*<>c<ܯrhZhy+vEw>k*#2_B~Ti395lIELҪݠV[<Bc::ok70"@n4]qA<[jG#yxɩ]=\N#5[O~{Q hr(LB"K2Q^Ex[.D3LA~g ·V:|5BO
RuSOJCJw0J:C tiMGs\ZhЂŝhFح~R݌,GuѪ>X&`Ú"?/i4FJV܁Q%S:~EO"m9 l0;5l~S(3neqDIQ8¾RZ'Wv5v]֧>oe-z!y/^|^T( 58`O9@?dfyHX/)*TDYضL1~:i$tnJ3)vsX3qxTuaL*}kᩲsGs`z(@
b/9WTuRW q\ʞ!.d*lI:SѻwqKPS1 [X]m:dHr"	Qׯɲ8zc5кBZf|N}ͳC6sZ' e<hj87:_!kk^IUǸ3>8(0*h|TTڷ{ٝ*<O[KR#E훋KyPKqͧAgӮ'Y;\I֚n:nO.+j\q7L*q#,@yUݖ:VM`;<Sr@r?2I܀b5LLg{YSߦ1"%^BkBθ6BDc{O	]ܙIUIĒ1f,'rX0رKunykc֏0Ҭ_?v j&ະ+/%ßɂrM]-[DWxfz$xl\PqUWu<Dy~tNqixaz'/Ư[<wO՟uX?!H`JRFyA_j؆=F-	=.P;wc?K{?H,j?u`CjzqRC}][DTaہ&yVZɧOT˜XE*<w`ݔӕFC68q#yǜT1Z!{ܑt7RT+UO)_*l{nGҌl.W`j@6s%?=~sd`m/NEEݫyŰLժt!.]Oe흘Z]Ӭ;L灝߈Uv>*T|\pl'Ghu%}xķm~TeuS_1tu yxWn6Sb݅RHvݭ|lHd>DOCQ]a	>C'BjrO5-YJ<Vj*/B`._s|힇/"8e\;'`W5Xj*9	3y?
乎~UZ	ԣaSM	hLbL6|_hZz:)ȩ.6hYdV̓tBHwHX0VAKEj!js@"<M&T%a)k	jOCF2$f+D1L=նd_>-}6Rw4sK^譍.t{/h]-	LM{,@?Yx\%4aFc_+G"(-QӖEgPv	f<8lZȦĸg_<cqb90 ٰZ;QzJa#\5g=Z)ЅgVfC5B`Wݹ\A5nI0$둗qmdf9#P瀬\b*#$qؐeD
zǏ+2C)[qλc-Vǿymn^F^	Åy_hK&P	^.&(Xtv[)6dt|<z|~5Ək;x4/I,jϊüC,-CB&Ѐ6m8	;)Y.ox*׻et'__N>:h#RJTG2absmRrFzCrqg
~^8.T,WFT*EZYfDMI$Yg|߲+-7Eyb3 ]M-X}l9cas $blfD7l6\k^V=dטof}HL)orM#Q/ޞ{O\(5NB,0_Xf+p+nߝ">}Mp;<RmwN+e9yV#
_1i~\P+K(u]+%FwW
xt&nwGޏR
irJY>95+nʹ%	%	oz%Ic,4߶x340031QHI-+)fYr%o?zf!DMFjbNIHё
;g?o<LQ~n*Hx^gkOZh_Eܘ:P% Y
O+țznRfS2LAybzzjHĩ{fTU~> '?1(Wڥv9fg8WnphUTX	Ru ԋ]7%a2	 Àc|ximport { createReadStream } from 'node:fs';
import { unlink } from 'node:fs/promises';
import { nanoid } from 'nanoid';
import { findFileByPublicId } from '../db/files';
import { fileInfoCache } from '../utils/cache';
import { formatCreatedAt, getErrorMessage } from '../utils/file';
import logger from '../utils/logger';
import { getBot } from '../utils/telegram';
import { locateZipEntry } from '../utils/zip';

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

const getTelegramFileInfo = async (telegramFileId: string, public_id: string) => {
  const cacheKey = `file_info_${telegramFileId}`;
  let fileInfo = fileInfoCache.get(cacheKey);

  if (!fileInfo) {
    const bot = getBot();
    const apiFileInfo = await bot.telegram.getFile(telegramFileId);
    fileInfo = {
      file_size: (apiFileInfo as any).file_size || 0,
      mime_type: (apiFileInfo as any).mime_type || 'application/octet-stream',
      file_path: (apiFileInfo as any).file_path || '',
    };
    fileInfoCache.set(cacheKey, fileInfo);
    logger.debug('File info cached', { public_id, cacheKey });
  } else {
    logger.debug('File info from cache', { public_id, cacheKey });
  }

  return fileInfo;
};

const buildTelegramFileUrl = (filePath: string): string =>
  `https://api.telegram.org/file/bot${process.env.BOT_TOKEN}/${filePath}`;

const cleanupTempFile = async (tempPath: string): Promise<void> => {
  try {
    await unlink(tempPath);
  } catch (err) {
    logger.warn('Failed to cleanup temp file', { tempPath, error: getErrorMessage(err) });
  }
};

const sanitizeFilenameHeader = (fileName: string): string =>
  fileName.replace(/[\\"]/g, '').replace(/[\n\r]/g, '');

const fail = (status: number, error: string): Response =>
  Response.json({ error }, { status });

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

    const file = await findFileByPublicId(public_id);
    if (!file) {
      logger.warn('File not found', { public_id });
      return fail(404, 'File not found');
    }

    const archiveEntryName = file.archiveEntryName;
    if (archiveEntryName) {
      const archiveFileId = file.archiveTelegramFileId || file.telegramFileId;
      const archiveInfo = await getTelegramFileInfo(archiveFileId, public_id);
      const archiveResponse = await fetch(buildTelegramFileUrl(archiveInfo.file_path));

      if (!archiveResponse.ok) {
        logger.error('Archive download failed', { public_id, status: archiveResponse.status });
        return fail(500, 'Server error');
      }

      const tempZipPath = `/tmp/teleuploader-dl-${nanoid()}.zip`;
      await Bun.write(tempZipPath, archiveResponse);

      const loc = await locateZipEntry(tempZipPath, archiveEntryName);
      if (!loc) {
        await cleanupTempFile(tempZipPath);
        logger.error('Archive entry not found', { public_id, archiveEntryName });
        return fail(404, 'File not found');
      }

      const fileStream = createReadStream(tempZipPath, {
        start: loc.start,
        end: loc.start + loc.length - 1,
      });

      fileStream.on('close', () => {
        void cleanupTempFile(tempZipPath);
      });
      fileStream.on('error', () => {
        void cleanupTempFile(tempZipPath);
      });

      return new Response(fileStream as any, {
        status: 200,
        headers: {
          'Content-Type': file.mimeType || 'application/octet-stream',
          'Content-Disposition': `attachment; filename="${sanitizeFilenameHeader(file.fileName)}"`,
          'Content-Length': String(loc.length),
        },
      });
    }

    const fileInfo = await getTelegramFileInfo(file.telegramFileId, public_id);
    const tgResponse = await fetch(buildTelegramFileUrl(fileInfo.file_path));

    if (!tgResponse.ok) {
      logger.error('File download failed', { public_id, status: tgResponse.status });
      return fail(502, 'Server error');
    }

    return new Response(tgResponse.body, {
      status: 200,
      headers: {
        'Content-Type': file.mimeType || 'application/octet-stream',
        'Content-Disposition': `attachment; filename="${sanitizeFilenameHeader(file.fileName)}"`,
        'Content-Length': String(file.sizeBytes),
      },
    });
  } catch (error: unknown) {
    logger.error('File redirect error', { public_id, error: getErrorMessage(error) });
    return fail(500, 'Server error');
  }
};

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

    const file = await findFileByPublicId(public_id);
    if (!file) {
      logger.warn('File not found', { public_id });
      return fail(404, 'File not found');
    }

    return Response.json(
      {
        public_id: file.publicId,
        file_name: file.fileName,
        mime_type: file.mimeType,
        size_bytes: file.sizeBytes,
        file_type: file.fileType,
        created_at: formatCreatedAt(file.createdAt),
      },
      { status: 200 },
    );
  } catch (error: unknown) {
    logger.error('File info error', { public_id, error: getErrorMessage(error) });
    return fail(500, 'Server error');
  }
};
<vpxm )&redirectUrl = (4;

    return new Response(null, {
      status: 302Location: redirectUrl\%|x&&NFileInfoQQ  as any;

  if (!fileInfo) {
   FileInfo(telegramFileId)R as { file_size: number; mime_type: string; file_path: string; bot_token: string }4
, botToken;botToken4a	
        	* , archiveInfo.bot_token),
      	U, fileInfo.bot_token]]'x &%5`cleanupTempFile,i)metricsCollector } from '../utils/metrics?, type TelegramN]TelegramFileInfo | null#metricsCollector.recordCacheMiss(); metricsCollector.recordCacheHit(=8unknown as ReadableStream#u[j"x(import { sql } from 'drizzle-orm';
import { db } from '../db';
import { getErrorMessage } from '../utils/file';
import logger from '../utils/logger';

export const handleHealth = async (_req: Request): Promise<Response> => {
  try {
    await db.execute(sql`SELECT 1`);
    return Response.json({ status: 'ok' }, { status: 200 });
  } catch (error: unknown) {
    const message = getErrorMessage(error);
    logger.error('Health check failed', { error: message });
    return Response.json({ status: 'error', error: message }, { status: 500 });
  }
};
P(x50D|
d@8xhЄ.Qcw[Mfg1zƨl?m6ʿm4pwў.KQ'b3ESiurD4J QJY.cBdiV#^'4_Y+'zrA0Y. "ݨ'nVxPQx=isȱ+fSCv*t.]^և%mR 9$  -qʏ//I=8xHz6QU*+3==}OL8`4?ƑRRNS3>HC`4FӝAuݮ?'g[XdlpӰO4w<K(-g<iyDA9O$!v
xn; No#1\ܼ9,.#Vz.AʁIih(>|ÖW{0wrxvDשx\-|h @}pf7 ~뢌B~0dGß\ԼGiBQ.7I*f_\{#6&kv gO}gh0}Fqz,S89>FqYyr@Swi2K?2Hg?͒s>M$5d
?8qgY0qxßS;4ވHZ<-.(¨-`Ww?}: hb3?;0Mߋ'G0kM[OpX@{z, 7{@TƢY>c|^x*B}4<j'K F\y1!0h&K9Xӈ kr;ŠJVг0po{D04;A)_f<˛d]KuS?A]'g` Qtxx5b	Nxzgk~ _Xn(l7(~Z=jk_Wa<xv
*d5$F^#3rnwE]{;O/:%B
9U3;,HNeO04SGψ8rYP0Xs2<Dy"		d7;?mOma$+ր%B⩷50G<-%2A3>v{|?X/3Е, L.(W~9$eH?R-Wysy䍴9Ŷd?a0\d.۔} &$9xΐnp +YZ2%аA:	lux-za,`
 cQ%3-xxB6([?3IW;y℧bj62c`O^_1k.JvB~cbF٭Zjg-P^^ /$0]hE(/`^Y"#Ƭ	홻Ն^&H־SaCJf"ڻ lRmA,jr<ZQU@o٢bOn${nR{$ eD`lTB!HlL+9Jtq7i2J1^m1]¸4nu	aeTqq^J^TE@+Y|Hb5U	]|]Pu
Gu:[7~ۭՖޮއU%6ܬi%U`<,H,pkZaY&-M|Ӹ:rF~x,ЏDȐR:9P.a!|^ւ]=HZ,OU1Ѣf$wr!)9Hvzi[IW2hiU(s[jm4t+cX)H=$Ti(WXCJF;0/
BN$RSD8tu꿝;ITHh~$Li	Z봘T*!ͯax$@C=iM2Ue=Ώqn*
Kڂ}֪HQ,	sCzvzYTܓRhŁ&>&
9\ue%v戏޾H!.<DjH,)";hfI>wIUâ:f{2!rAaCZQHoQ:Y!J~3(o1W9Z[s8y!N&>M<ua<I~	33B6CТ0HY.`yѺ8O`BazI6I@sMf"	L<v+wx(0"$#Hut="[8/_r=N,\FL Q$/  ɃXLfN'NǢ9ۏ'7zǗ<BGLblzi4g*
>;<zòPRADmKb;ziHÂSCGw5sdW[ܐb p]ҫX&d=ԕS9:|׷#pѝPGkF#46$mX<Q>ĊM_U!Rۖ^0#4lYG7TKjBy5]*~0xL*#0LW1:~yqS?O%TjcCi#Ėi<3FLp?N܏<#xd10<vd`Og{;ŠV'f6۩y>JLx9!Of0cjJml`H1ݡ2 lS!â^)NnYOSдfER͂Z1b՜-)tcW_˝ g}wZf{5;t!qg`e0}6ͦ-HKoe_YB</\fgܮI3U^ᅧ&a!;Ns}%?=]*4mɷ
756WkFg߶WWڲ!A3NkKnCb%U(`*`RJEҒI#2#M=soOShSzƍ
(hG)qʂN5|=9ۙ hL=A.vMtHho#ҁfp^-/U\:u`NXRDild\܂C=tJKL]>`ݝROuS7&⺖L7xvOnCRs74
WV1lmD卼_e$Na^懞'K kfR
ʇT
	W8)ޏ+o)WIQ3E[uHЇn]cm}cfS@QO֢n7mo	d29W$!n
]	^L;g7;nRIMHLRD_eU>Pr'~}T`%[Nt#D
`ZJfw?uSDk~)̉0_Tݟ\s@Eҁc#04 M
?&FsXjAepj][8.j85jnO1Iq٠;k9)mzQg	yCyZ.Z>+,mVt8 a9勘;xm:ֆe_*&ڙv.r9d෡{ ˿OͤY:2tT邜!~h
TXY
^V,j,WYK^en	E>Nfz]`Zc]hT|>sݫlb=o7(e/K:s]z?^Mv v Ɨ늕xl;,xzm LlpjK}Ykr17Wh}6wJ"`Ty3]jZNQ`LTeʉ6fԿFY1yB!$ʽ2(<hDMS(숳FnRe>EX}IA<ѩMB܊?y$8*d2SўdAAv΢<R7!-	 })2n)g_kZϙ|Ը·. F0Q]j'OEۅFHc}jVGx8zٱ}[[ilBETtl6((	Z"iv[[/|ţ4/fBα5:-pQ}uxZc9إ/c7;̚^f=R;pJ[_<DTa>+*{:Ia*Y㣿eI7 -n\aB:XS_۞w"[VK7FSgЏ}#jCQPM*a灝7?:oƭ,r:eܫ73/2L;k[Eo[,X\qAhP2ti8V!̹F`q>3B]6c@5nkP5{6w.on6l~Gf~@KS9(] >3-F)L?I C>uE_`:% JWn<~K^n2dXwAS%~&Z.o#vŵ;lK3н4
^r5ߚt킔P݅pɍ4\Փ c?PuIݜjӦ0Qr-ϩ{ͣ/^H$eUKڗ0?}),(>]A,SU.2\[6>9Tm*#^LtEYtRAq&٤$o,A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`x4G>Qconst publicIdParameter = {
  name: 'public_id',
  in: 'path',
  required: true,
5.o!};

const fileInfoProperties = {
'6:=P5604a';},
};

const uploadProperties = {
  ...fileInfoProperties,
kGEAh8download_url: {
0)/},
};

const objectSchema = (properties: objectF&
objectSchema(uploadProperties/publicIdParameter publicIdParameter'XobjectSchema(fileInfoProperties')YZx !>;stream<;
           w
Li!
/413"equest body too large1'_Request body too large"lDownU
$-Redirects to Telegram CDN for direct downloadh0 status: 20004fKuxYsԶB0̵]'p6$ЦCLtv׍_HrMe%!mt%kGRV$QɎM%,Ff$CdA*HPV)OEMgybLiI*K[Rf9
8qNӬLT̻tSK-kK%
:i6Xy2i<=GLU)Ur{gꁪPDdh8{{%GVjJr`' &͘|ytfkdn+_^fihǂ<=ea2+YfjMPk̔ɶ3Mt줯?`8ed|JF~UYk6]euYnZਐ<+ghm$$)l
bH"ƠDM1 1wٔG,$R|<R\D"!3<<K_WdPYJ`WpwgіhƏG.72(j%ԫ<j3_&=6%}|
N-Y6L}'=knӖ_ǷCh{#w 	*i
Vʀ/ǿ|:=|?;c<xim{?>9=z(r&3%P7Ɋy::c̸{-)&YIAOL𢡄䌖M}ۘTLH~;_4P6tma)GFmۈ0,r~ӌz3O;v**TUO;xu=Ӎ/+.ZUF7эƧ0Z|rk.9`d@z>U%W~]g5bN=xQʣG1Ḧ́7s3XrQXvO7Oê1ES(VBW/mH9:,3QԜLʄA4+TKI
CK0-<Q|4Մ!#':Pz9l%oXkn4VU	]мA6h_G"yD&0qviY%&.t$*[߆mMO=+grntbĺ30A	6
ݴ'`/_pŎͬFa̠f?eVom7ӈUK497F-{/{	׍ex]%V\@я̶)fE41!`ήhk;bCOQ1)h.hR9<1(\s^]jd,4ϯ]fJШݵFːh!EO[1+/Vߣ3^4@P9vHۿتU1xgUӲvid̬26h c+b7 75wj^O:U?Anc3ΰw[9nhr逫hў
FNun&p8,G^qwxi}iV-.oT
}wF{9'VF0"sݒj<5@kgmʹ1DǨ_zL,h}"'ˊ-#bWdyT*îՉ<8ξ`o%$Zr2*%N͡sUO PXAƢ䭎2(aP@d\o!79طZ!}e?p^;	j#0'M>F]D\/8G#YH@2͍;<86*8йo)gʢ900c{bw Cꑧsa,X41^yG/-[x5:}6	+p
la*&  >I/R\獹,mOj5<Xg+\TZӚW bΦzݦ8Ko:\v		{{A5nqA\wYkSG2ޑHef=ԗ0Gލ	)(y	xv]G\.;t*7}zF~$w%E]|V;_RA]$ϊL~cbfMNDYlUM' ^qLQ2Dh5]xzc4-}1-41lG)`<*->sblڪWBdz?~Rֈ<DIW@2%Uzuz#+:W!:?O6~D3^,x`ػއy1Ĺ	 ?Fdl2䲳Ufv17@HL%A|cwu{HNz/Uoƭ'ubLߔ
G8<%ơ?G/vOļ _{ѿ[2ހj|Rx%GDimport {z,JenqueuePreparedUpload, type PreparedUpload } from '../utils/uploadBatcher'|Hconst getContentLength = (req: Request): number | null => {
  const valulength');
  if (!value) return null;

  const parsed = Number.parseInt(value, 10);
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
};

const rejectOversizedRequest = (req: Request): Response | null => {
  const contentLength = getContentZLength(req);
  if (contentLength !== null && contentLength > config.maxRequestBodyBytes) {#@'Request body too large' }, { status: 413 });
  }

  return null, maxSizeBytes: number^V0Mcif (sizeBytes > maxSizeBytes) {
        throw new Error('File size exceeds upload limit');
      }
v
]9    const oversizedResponse = rejectOversizedRequest(req);
    if (oversizedResponse) return oversizedResponse;

    if (conten*if (file.size > config.maxRequestBodyBytes.)0File size exceeds upload limit' }, { status: 413&, config.maxRequestBody7>enqueuePreparedUpload({
      prepared,
      fileName: finalF,
      fileType,
    });
^"y
      V,> ||
      estimatedSizeBytes > config.maxRequestBodyBytes
    .:
_>enqueuePreparedUpload({
      prepared,
      fileName: finalF,
      fileType,
    });
^"ͺ4x DD6aleanupTempFil)metricsCollector } from '../utils/metricsd$const startTime = performance.now();2metricsCollector.recordError();N finally {
    metricsCollector.recordUploadTime(performance.now() - startTime~yZxnF_1B%Rlĉ5&Fl>$i<"Gm~~~ɞ3%_b999T()hF UfEoPqW $'єaN==Ƚrr8/$n$.OfrөxH4Ъ0y
|HۨT㢖)܃4RvYzFOexV!<.ūDH3>EY	="9B;b	~\TY&KFK t6#MDԞX 	9z
4>,+fOWr\/XUׂ^R\`QIJQ=}^	% {9$Q",SK4FEAvZiGϿ;"-iVG)s\9y? -"AA?F'`錓[IIWʟ%r3FY Z0Fxq ,p(+PhfrEk:VBn/ǧN݃*_ʠTHeWJ)FD
d[-)IK'Ifpqt)]Jc} BhA<MQ@){_E>/wpi5<( OP}ʿqqs[m%B9",rJA.(WJO(#z%]Jkp%uvJw*Z~&:pG S\qqT#?{*0;29h3^9郥Jm{dCZaӂ5TcNR5;ڬfQW<s[ۻtWI*FUݭ9V}kSlͳb90,:F*sj CN0KZz{}z/[d5ALV#PCW#n" 8&h*GJ*,9_IU"r($&[TyXlejd?=VƺucW~.^ZHddaNCZd҈<KH0X$^5\ۧJ~@bG#dQ|*[drܳvg@LQ/.UixfUksŏ4ͦIc5bHWT ܅;*V%QɤФ,Õju/vվG^})yH?N8?=g*v4KTKSiĿ^Eyl'k2wEgn@vKu]$
HszrO^v4lofP2z$zZDh2bOTNRJM*PkhƌރֵCs4)׊j	"RU+0dpA}4U^1ș0@y6+ȡAÄ(e?v^9	mJN`"&F~X2uIÖHzQQ]y%4.F4 bȠgRr޿ZI Ltkx
19 *C1KiR-H!6$YJ\ҙi2'Q=$<\`1!  Hy+N@ԌZ|rq^dvYs/XH4ʽUY6Y=Z˦	Q!K8b`ʱ
{7'[ֈX!{`-}{V978ӀiG])lf	jvS8KdgK^"58l~]iB47gػYw]Mjl%ṕ.]^iƀe^̅(yEjG*,>K:rVW6`?@s"3k쉷9rӫ~+Κb	u?1F;hVSjTKm{M,rW`-g#لYvA?j<smۑ}-@/5׷f
iКnHvtv͕}d~Qsnm~kN;6dkr M#bѵhh7yn
u{K
M]-vκq>_dƹ-B[9u.z*zjp;-8R*jiAĜ!E/P[\%SM2Θ>}o,FJ~ӗ;\=[j?Щ㻨mD)Nޝ1I»%$ʃܐћ|Jҷ{15݂|l?H8t#{dfɵ* ץog6jFߩA.<oQ[9zcI.{e}%AAIٷ[bٍz֭ӊ]V_hV䦨@S@yH ]vyt_|n4-ؚ<]]5?;2?fzLD89?8Iu! C.M_K4Xo{+Mx340031QHNLH+)fuNT<֍TX!j2sJvT]˜,tBd͘9|ixkUh2TAN~zzjHLV_'YlPE%E U<ynic	TUQbIOfnf	H]ԒLD7m!:i 5O~=	ŔL.o B1EN^yRiӼPJRsRӋsAfwʌ}_XipwhKSKᔧy.{CY
/&U[ZXuphTfax^ۼUg{Tm$P/uL:}hό{NbqL kbx,// Simple in-memory cache with TTL support
interface CacheEntry<T> {
  value: T;
  expiresAt: number;
}

class Cache<T> {
  private store = new Map<string, CacheEntry<T>>();
  private ttlMs: number;

  constructor(ttlSeconds: number = 3600) {
    this.ttlMs = ttlSeconds * 1000;
  }

  set(key: string, value: T): void {
    this.store.set(key, {
      value,
      expiresAt: Date.now() + this.ttlMs,
    });
  }

  get(key: string): T | null {
    const entry = this.store.get(key);
    if (!entry) return null;

    if (Date.now() > entry.expiresAt) {
      this.store.delete(key);
      return null;
    }

    return entry.value;
  }

  has(key: string): boolean {
    return this.get(key) !== null;
  }

  delete(key: string): void {
    this.store.delete(key);
  }

  clear(): void {
    this.store.clear();
  }

  size(): number {
    return this.store.size;
  }

  // Cleanup expired entries
  cleanup(): number {
    let removed = 0;
    const now = Date.now();

    for (const [key, entry] of this.store.entries()) {
      if (now > entry.expiresAt) {
        this.store.delete(key);
        removed++;
      }
    }

    return removed;
  }
}

// File info cache (1 hour TTL)
export const fileInfoCache = new Cache<{
  file_size: number;
  mime_type: string;
  file_path: string;
  bot_token: string;
}>(3600);

// Cleanup expired cache entries every 5 minutes
setInterval(
  () => {
    const removed = fileInfoCache.cleanup();
    if (removed > 0) {
      console.log(`Cleaned up ${removed} expired cache entries`);
    }
  },
  5 * 60 * 1000,
);

export { Cache };
H9x 
,export { Cache };
yxYmo6_]edM5M Z4͇!Ym5I4_$RXQ@sG^]YH%#ʎ"+iYJɀ>4K]9|Kv)(pAG4L[I}>B;ٌwvb	=,py5K4RhBEBp?2Zeh0djF!Om`EuExEwm6ʓ,ha.4`4= Ώ5h#trzv<|aVDox(oqh(MA4C2ˌe>P-sG4UAň۳P!
x8K`Ւ--~4ctFLʍN_wRI݉btEB<(̓^]O<ew8
J:pǵTy Vk[<rxxH0ꂎ,w㣡uiT-*kX.'Y֨cm3pZ}`gXVkR7S;"hhu9<
'lkD"In-H6'}(4Z9ЩyNŃeYBL$^t4J^cP6tOmi^9,ۥiYx7lCͪK;DJDc5P8C#tt<;Z<U&7Fy4w01\ytxo9oy
t|H:k6ʓ NL19r]׬U PQ+,(~7$/](D*'8âII5eP鱫}E}Q%ըګ):V^V[#VLuDƖ2=XM+avlyyɉsУBiEˠ3K:5G8 {yg^B[@3C/9ԑڛyoԧhO6l`s-ir z<n]L\e[QG.mVjdN|B~[	x89F>?RY޿PLV%'oQ 7c	Hq{Ӷ<o\^abCkZ^^5 Z)[FhjV7Lo| :
aQkcu3U*(8FfSNRē&u5M@_։!Er44qq5*ōB,%bVʞpPõ;YL\d{b[ړʍCw1.wqbJ֦0Ɇ	xNkSrEz%<k(1lCveyS'o/h^BlyAy.ۼ`J/XVm0[r^1}0V)d9>r2-W
3WQj7lɏi,1wLmʱ̒OccI8xS┙]BbSlXj;(f1X[t/S[>i]@Kԯ}M>)6A[<-Xdc+ѓ;t=ݧ]PCW*O(jk*	=f]u2]\RY;yZ{j`/nU{g.<3"i\4@Saнs&E{88A;~]U,?M.Y=R#_\lo^wbSrf]K>BB}QLȁ4\U^~]=lTM[kIM.E*[{0$jSo|_6 rF<w;)SRĜ2ؠ:ꋋx/0&P?v0Gۮj\uKi.K1[Vi-Fp~%G h8$0cf4F͍
%>xH76Kimport { unlink } from 'node:fs/promises';
import logger from './logger';

export const cleanupTempFile = async (tempPath: string): Promise<void> => {
  try {
    await unlink(tempPath);
  } catch (err)a {
    logger.warn('Failed to cleanup temp file', { tempPath, error: getErrorMessage(err) });
  }}U:zKh&rbxm?k0w$5RI3
JgW`Kܺq=-:{zKW8[S&v z@-,1*1Tj4r2cǤMgf .+2 vOj.$R[)ŒO
ɩ9fQŜ6ʘ 5wQo<#m%Rw`KX}hmt/ɴop4SM4x@import winston from 'winston';

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json(),
  ),
  defaultMeta: { service: 'teleuploader' },
  transports: [
    // Write all logs including error logs to file
    new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
    new winston.transports.File({ filename: 'logs/combined.log' }),
    // Console transport for docker logs / CLI visibility
    new winston.transports.Console({
      format:
        process.env.NODE_ENV !== 'production'
          ? winston.format.combine(winston.format.colorize(), winston.format.simple())
          : winston.format.json(),
    }),
  ],
});

export default logger;
~xwimport logger from './logger';

interface Metric {
  name: string;
  value: number;
  timestamp: number;
  tags?: Record<string, string>;
}

interface MetricsSnapshot {
  uploadLatency: { p50: number; p95: number; p99: number };
  uploadThroughput: number;
  queueSize: number;
  errorRate: number;
  cacheHitRate: number;
  botUtilization: number;
  timestamp: number;
}

class MetricsCollector {
  private metrics: Metric[] = [];
  private uploadTimes: number[] = [];
  private errorCount = 0;
  private totalRequests = 0;
  private cacheHits = 0;
  private cacheMisses = 0;
  private maxMetricsSize = 10000;

  recordUploadTime(durationMs: number): void {
    this.uploadTimes.push(durationMs);
    this.totalRequests++;

    // Keep only last 1000 measurements
    if (this.uploadTimes.length > 1000) {
      this.uploadTimes.shift();
    }
  }

  recordError(): void {
    this.errorCount++;
  }

  recordCacheHit(): void {
    this.cacheHits++;
  }

  recordCacheMiss(): void {
    this.cacheMisses++;
  }

  recordMetric(name: string, value: number, tags?: Record<string, string>): void {
    this.metrics.push({
      name,
      value,
      timestamp: Date.now(),
      tags,
    });

    // Keep metrics bounded
    if (this.metrics.length > this.maxMetricsSize) {
      this.metrics = this.metrics.slice(-this.maxMetricsSize);
    }
  }

  private calculatePercentile(arr: number[], percentile: number): number {
    if (arr.length === 0) return 0;
    const sorted = [...arr].sort((a, b) => a - b);
    const index = Math.ceil((percentile / 100) * sorted.length) - 1;
    return sorted[Math.max(0, index)];
  }

  getSnapshot(): MetricsSnapshot {
    const errorRate = this.totalRequests > 0 ? (this.errorCount / this.totalRequests) * 100 : 0;
    const cacheHitRate =
      this.cacheHits + this.cacheMisses > 0
        ? (this.cacheHits / (this.cacheHits + this.cacheMisses)) * 100
        : 0;

    return {
      uploadLatency: {
        p50: this.calculatePercentile(this.uploadTimes, 50),
        p95: this.calculatePercentile(this.uploadTimes, 95),
        p99: this.calculatePercentile(this.uploadTimes, 99),
      },
      uploadThroughput: this.totalRequests > 0 ? this.totalRequests / 60 : 0,
      queueSize: 0, // Will be updated by queue
      errorRate,
      cacheHitRate,
      botUtilization: 0, // Will be updated by bot tracker
      timestamp: Date.now(),
    };
  }

  reset(): void {
    this.uploadTimes = [];
    this.errorCount = 0;
    this.totalRequests = 0;
    this.cacheHits = 0;
    this.cacheMisses = 0;
    this.metrics = [];
  }

  getMetrics(name?: string): Metric[] {
    if (!name) return this.metrics;
    return this.metrics.filter((m) => m.name === name);
  }
}

export const metricsCollector = new MetricsCollector();

// Log metrics every 5 minutes
setInterval(
  () => {
    const snapshot = metricsCollector.getSnapshot();
    logger.info('Metrics snapshot', {
      uploadLatency: snapshot.uploadLatency,
      uploadThroughput: snapshot.uploadThroughput.toFixed(2),
      errorRate: snapshot.errorRate.toFixed(2),
      cacheHitRate: snapshot.cacheHitRate.toFixed(2),
    });
  },
  5 * 60 * 1000,
);

export { MetricsCollector };
6#yx] P// No imports needed — logger used only by setInterval which moved to index.ts
Zd"xU]O@|ϯXbЪ}h*Ң
&ݙ$lc	û3	L,
Qtq8p;-Y/RB_u	4[PVGz<B\a4~gB=<BA;BB<Qi>FgngJK.VViN̫?norvCOoz˹dsTlK/7AzZp>!N	L(,3	ye]%µ` |jN]NT Qꄓ2s|8̿VR$lݽPDxrs/RO|ۆgκRe]+M̦]a}NA&g4[$)	1<I%QDv\NF@Y;ʅFB*>O.ˌ^Eؤzb=mhw9#zgʕ+2|WꛑacSqyH
;IR.8jp=8ζwPʷNLeRC#:Vuň	&CzDΒtKtg[͡-D((9OY3(dq|"46{Qр|zڋBw|ωe묛/<l WGU&2zg3.޽PM*^a"Ul=zVFdͲT#^S/ia	mŒY=>6Zqg%;tRxϏt`ȘYcryzq͎mЧ񫻸Wس2vѨ9£i;ᴚIf$_xU]o0}WIH(eHZxxMf;U>%$@&	||7$[L@/A9vNPYbXh%HN9<w 2l hcJ3DpmgGC$"dZ^̋tov3 
@5uybDNpyuz]M/M ,fj$So z^~ {ƶ=z?hu17I<1jÅq-0 	]nG`r-u â`}Z8-;h2f7Aׅ?N/A4DQ4ӁA2tuJRUl\4<'\P+s±ʍĥ6du7hWfi:
|l,
h!.-%TJm$r)G"!p2և3q;:rϖ[FDHVb$\Ju7z$nӞT	ѶƘ`Nh?.QwF$TySs/l!hY7bu\#F-JI	Dn^тTgs|Na׼;Jڎ0_Xbj&2{;CNj+u,Z^vP|MdiOSm:|&?z7SAݹdu%e"sfHm
}52wmwF(Mɐ!*I{&ur`4eJ*ZDR<j*9haOCu-;إxE 100644 auth.ts Wmy!s1=/100644 xml.ts 7 ޮ&;;?kQqxZSa*ϖ!L
%0yPyPEtѿII{-`.i^dc:bd|:c|
rBH?4uf;6*X2t[BG#OlM(fG-GT0w`7Ik_C $̊"/XQ,VVFy}:; ގY=?8pt??x"w-{~1.f#s7J8NY7cw+A̧d*]v7MQ"4;gc탊BcV8V-}m;\l1>"V;K}%}nu$9{L}k\W;Py6jtrtuO|p{M9ǔJ&j>yg"eacD%;?cZ-F8%[lp8S߿סJtMNi4ֽ ɓ<`h>ˠ	D
)KkbU@y:#^KܓNvC*od4LxryCټI]"y·WW4=dK#0I*h;DI,$%9n2ŋn<)ʐV}U}y*T*喜,Meyd:lvB7xfk.}f$0
M%҂Ly|lzh2OaY+G-iRK~o/P8Hg>3`e|(X=C@5yx -ꪪ~bSxծ#JU;,1Y%W4Qr> dQ06~8 $ɔ5,l()]{^C~|=Khfyhzatb 0pg%(?X1]:cLw;E%Et4rnhYIesh@;N	qf3<1${3?Js}nvX 5҇' {l{w0:E
j1
{E _%aV=w* 41X% JpiO$:N 'n$ $"f3+hz8 VxrF摺pFQE'wgīZ]ʢP%qMJY㼀e֒&0·\O/xa~GSsJZ}-CJ-, avE7bXk/WPuGseh
UWrj>[jƪ^:it=esc,ە|L7MXe)uLL"$Č|_>SQ^e`@iңyވZb6^Dľ'!nW.4
O]M
tL	U'>y J͙te!qS/$A_Dե3猟BA%2hPsQ6U;;\XvaZ\(ATc(ˍMci5Ap0Y]NvvW'O	;fNT	RU鸷9b4^+NHLbcV%qi%7m~M@Zϴu00>@%helS@%3FTYZuTO%تZ0e\Ƅ\O~4sAPn5gam(31М`Sʦ\MuWQj<dnA_:8}`[33jv;Dȓ-NVez^ƭXVQse-jH	5BXőG*3N@2%6df;p?-	B/?wvxjO93gUdPyMeܺ!Unl9cV+><I ,Kvjee]xl^F=UBvJT)_6ip#< ;d+PF<E!o4)钍'tgrm<vc6K	t+CG-Э[xewִ=O&~X(g*Hˬh؁U{6RbU߶"*}ARm:تr'=$1]DPz|:R2FOؼ23)"`aXj N[r{IZ*-K/#?IshodKC|*k=ǉBN9B#p >wmOT]Gۇzjb*\!Ξy:dpǓi/Oە| 	."{Pkl[{-OObӷGnUk異ĨMo7dxYNFb{B6RU9DTBBDQc/6>DO> RDffAM;>&ZM`gd{!b4mj9޲=z%|VQTWLݧadO.	Lnu:%(7$h8q>M]lc~QiH;Hf,[t$
cFe0!H&-lww_&l͐NiWN]B't}s/ݝA: kv}p*M:I3Aݽe,4dð0?%M7t՟WV7VV.g%50@K3%|Hτh׺Ru{,kC[&Ne!
l2؀V"%Z\/U}C/z=}aˬઍ?RGh!XQ@iNL_L _xPfro'xpQ[d4Rd4bzM.1.F,N;z\O퀏BA/&&TYk.nȥ7XNagͅbU
ilJH']	?/_eJ@.}beN.)Tu@.GM8/YL{-I?FRX`\ɚ Bs8[(B=t,k8"&O![|N(A9!NvvO QƂ$+lSŪ4ҩHf<cFQe%`y2XHVQHtE6#sY5]@,AC> NicW' #E%s+e\ 3s5PJJj+2x6_nkmUy۩S+f
UK!`ȆxF(7j{^yMזJ:z#7	l0F!T׷c@U.mhc\oIS{WSBoޡZv.\hjhj(ښ6lt\kVTAkd5.kmQ>aEcMŭj@#>>\[HJdgyr=6o27ҍ\X^Q|ħqĨmLNKBW01s+F5y$-bx13CYkRK>߰'^1fC2mCvnsa(e0>\ ׼EUCQ^Q5>5+zўlT1RrpܯdD9ű!	Y{"wr'6@ lr`I7|0I'XCK;'5ո%lZkd)-VI~KmbŖ5爛
F;|Ot7>)pݚd^+9bw\s߼QkDF$?D'aGhIËН]ڰQSmԣLlL|de4(		XOuq(GxOO e:ڪm< q(Mnڥu͕/pԥֳ~2sO_qc8KμԞQJUPsZ`\'*F5"sV)Qcr.y"8q؀9Pr Ք7P\&אD 9^	5Ep!m#pk]$@."fJ)Ksu;Tlf~"}}Wp~.L~.c.Gː!̭xZ"import { Telegraf } from 'telegraf';
import { config } from '../env';
import logger from './logger';
import { enqueueUpload } from './telegramQueue';

const botTokens = Array.from(new Set([config.botToken, ...config.additionalBotTokens]));

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

let currentBotIndex = 0;

const rotateBot = (): { previousIndex: number; nextIndex: number } => {
  const previousIndex = currentBotIndex;
  currentBotIndex = (currentBotIndex + 1) % bots.length;
  return { previousIndex, nextIndex: currentBotIndex };
};

const sleep = (seconds: number): Promise<void> => {
  return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
};

const executeWithBotRetry = async <T>(
  action: (botInstance: Telegraf) => Promise<T>,
  retries = 5,
  attemptedBots = 0,
): Promise<T> => {
  const currentBot = bots[currentBotIndex];
  try {
    return await action(currentBot);
  } catch (error: unknown) {
    const errorStr = error instanceof Error ? error.message : String(error);
    const match = errorStr.match(/retry after (\d+)/i);

    if (match) {
      const { previousIndex, nextIndex } = rotateBot();
      attemptedBots++;

      if (attemptedBots < bots.length) {
        logger.info(
          `Bot Index ${previousIndex} hit 429. Instantly rotating to Bot Index ${nextIndex}...`,
        );
        return executeWithBotRetry(action, retries, attemptedBots);
      }

      // If all bots in the pool have been tried and hit 429, sleep
      if (retries > 0) {
        const seconds = parseInt(match[1], 10);
        logger.warn(`All bots in the pool are rate-limited. Sleeping for ${seconds} seconds...`, {
          error: errorStr,
        });
        await sleep(seconds);
        return executeWithBotRetry(action, retries - 1, 0);
      }
    }
    throw error;
  }
};

interface ForwardResult {
  telegramFileId: string;
  telegramFileUniqueId: string;
  storageMessageId: number;
}

interface TelegramFileInfo {
  file_size: number;
  mime_type: string;
  file_path: string;
}

interface TelegramGetFileResponse {
  ok: boolean;
  description?: string;
  result: {
    file_id: string;
  };
}

interface TelegramGetInfoResponse {
  ok: boolean;
  description?: string;
  result: TelegramFileInfo;
}

interface UploadedTelegramFile {
  file_id?: string;
  file_unique_id?: string;
}

interface TelegramMessageResult {
  message_id: number;
  document?: UploadedTelegramFile;
  photo?: UploadedTelegramFile[];
  video?: UploadedTelegramFile;
  audio?: UploadedTelegramFile;
  voice?: UploadedTelegramFile;
  animation?: UploadedTelegramFile;
  sticker?: UploadedTelegramFile;
  video_note?: UploadedTelegramFile;
  [key: string]: unknown;
}

type FilePayload = { source: unknown; filename: string };
type SendPayload = { caption?: string };
type SendMethod = (
  chatId: number,
  filePayload: FilePayload,
  payload?: SendPayload,
) => Promise<TelegramMessageResult>;

const sendMethodMap: Record<string, keyof Telegraf['telegram']> = {
  photo: 'sendPhoto',
  audio: 'sendAudio',
  video: 'sendVideo',
  voice: 'sendVoice',
  animation: 'sendAnimation',
  sticker: 'sendSticker',
  document: 'sendDocument',
  video_note: 'sendDocument',
};

const extractUploadedFile = (
  result: TelegramMessageResult,
  fileType: string,
): UploadedTelegramFile | undefined => {
  if (result.document) return result.document;
  if (result.photo) return result.photo?.slice(-1)[0];
  if (result.video) return result.video;
  if (result.audio) return result.audio;
  if (result.voice) return result.voice;
  if (result.animation) return result.animation;
  if (result.sticker) return result.sticker;
  if (result.video_note) return result.video_note;
  return result[fileType] as UploadedTelegramFile | undefined;
};

const buildSendPayload = (fileType: string, fileName: string): SendPayload => {
  const basePayload = { caption: fileName };
  if (fileType === 'sticker') return {};
  if (fileType === 'document') return { caption: `📁 ${fileName}` };
  return basePayload;
};

const getMediaGroupType = (fileType: string): string => {
  if (fileType === 'photo') return 'photo';
  if (fileType === 'video') return 'video';
  if (fileType === 'audio') return 'audio';
  return 'document';
};

interface MediaGroupPayloadItem {
  type: string;
  media: string;
  caption: string;
}

const buildMediaGroup = (items: MediaGroupItem[]): MediaGroupPayloadItem[] => {
  return items.map((item) => ({
    type: getMediaGroupType(item.fileType),
    media: item.fileId,
    caption: item.fileName,
  }));
};

export const forwardToStorage = async (
  fileChunk: unknown,
  fileName: string,
  fileType: string,
): Promise<ForwardResult> => {
  try {
    const result = await enqueueUpload(async (): Promise<TelegramMessageResult> => {
      const filePayload = { source: fileChunk, filename: fileName };
      const sendMethod = sendMethodMap[fileType] || 'sendDocument';
      const payload = buildSendPayload(fileType, fileName);

      const uploadResult = await executeWithBotRetry((activeBot) => {
        const telegram = activeBot.telegram as unknown as Record<string, SendMethod>;
        return telegram[sendMethod](config.storageChatId, filePayload, payload);
      });

      currentBotIndex = (currentBotIndex + 1) % bots.length;
      return uploadResult;
    });

    const uploadedFile = extractUploadedFile(result, fileType);
    logger.info('File forwarded to storage', { fileName, message: result.message_id });

    return {
      telegramFileId: uploadedFile?.file_id || '',
      telegramFileUniqueId: uploadedFile?.file_unique_id || '',
      storageMessageId: result.message_id,
    };
  } catch (error: unknown) {
    logger.error('Failed to forward file to storage', {
      fileName,
      error: error instanceof Error ? error.message : String(error),
    });
    throw error;
  }
};

export interface MediaGroupItem {
  fileId: string;
  fileName: string;
  fileType: string;
}

export const forwardMediaGroupToStorage = async (
  items: MediaGroupItem[],
): Promise<{
  storageMessageId: number;
  telegramFileIds: string[];
  telegramFileUniqueIds: string[];
}> => {
  try {
    const result = await enqueueUpload(async (): Promise<TelegramMessageResult[]> => {
      const mediaGroup = buildMediaGroup(items);

      const uploadResult = await executeWithBotRetry((activeBot) => {
        const sendMediaGroup = activeBot.telegram.sendMediaGroup as unknown as (
          chatId: number,
          media: MediaGroupPayloadItem[],
        ) => Promise<TelegramMessageResult[]>;
        return sendMediaGroup(config.storageChatId, mediaGroup);
      });

      currentBotIndex = (currentBotIndex + 1) % bots.length;
      return uploadResult;
    });

    const messages = Array.isArray(result) ? result : [result];
    const storageMessageId = messages[0]?.message_id || 0;

    const telegramFileIds: string[] = [];
    const telegramFileUniqueIds: string[] = [];

    for (let i = 0; i < messages.length; i++) {
      const uploadedFile = extractUploadedFile(messages[i], items[i]?.fileType || 'document');
      telegramFileIds.push(uploadedFile?.file_id || '');
      telegramFileUniqueIds.push(uploadedFile?.file_unique_id || '');
    }

    return {
      storageMessageId,
      telegramFileIds,
      telegramFileUniqueIds,
    };
  } catch (error: unknown) {
    logger.error('Failed to forward media group to storage', {
      error: error instanceof Error ? error.message : String(error),
    });
    throw error;
  }
};

export const getFileInfo = async (
  telegramFileId: string,
  telegramFileUniqueId: string,
): Promise<TelegramFileInfo> => {
  try {
    const result = await fetch(`${TELEGRAM_API_URL}getFile`);
    const data = (await result.json()) as TelegramGetFileResponse;

    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()) as TelegramGetInfoResponse;

    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: unknown) {
    logger.error('Failed to get file info', {
      error: error instanceof Error ? error.message : String(error),
    });
    throw error;
  }
};

export const getBot = (): Telegraf => bots[0];
b@^hxD@@type FileInfoResult = {
  result: unknown;
  botToken: string;
}?
let nextBotIndex = 0;

const claimBotIndex = (): number => {
  const botIndex = nextBotIndex;
  nextBotIndex = (nextBotIndex ++botIndext, botToken: stringAPcbotIndex = claimBotIndex();
  const currentBot = bots[botIndex];
  const currentToken = botTokens[b4, currentToken]nextIndex = nextBotIndex;
      const nextAttemptedBots = attemptedBots + 1;

      if (nextAJbotCnextAttemptedBots);
      }
 export \  bot_token: string 	string 9let lastError: unknown;
  for (const activeBot of bots) {
    try {
      const result = await activeBot.telegram.getFile(telegramFileId);
      const fileData = result as unknown as Omit<TelegramFileInfo, 'bot_token'>;
      return {
        file_size: !Data.file_size || 0,
        mime_type: fileData.mime_type || 'application/octet-stream',
        file_path: fileData.file_path@ || '',
        bot_token: activeBot.telegram.token,
      };
  !  lastError = error;
  Q  if (
        errorStr.includes('wrong file_id') ||
        errorStr.includes('file is temporarily unavailable') ||
        erarorStr.includes('retry after')
      ) {
        continue;
      }
      throw error;
    }
  }

Lget file info from any bot', {
    error: lastError instanceof Error ? lastEa$lastError),
  });
  throw lastError;&"0NnextBotIndex];

export const getCurrentBotIndex = (): number => nextBotIndex;
Owx @04kNxj@s'نRPEtr=Qi+J[}uZuw|g<Vj[ԩZ.bHlFfB>AdFY?1[?q^Y`oݯg+p	\/kp	=L܃LTDh%5Ai^A@dz|
	VK4Г8 	AvQd0BB4X+Jgq3JrKjҚl+,r47$Xfiц|\\6q!R90MsNb߿	BK>"L<ʘyK<iO
ܻwZhGx OF	(ﴃ5|)WI,;nҳj>4ZetMa]%Zds15z3-Ļ
u=F.BIzD-/aYaƸeq/>+zh~QK?y$yxB 	%{ config } from '../env#0config.uploadConcurrencyQxW[o6~aUɊ=qM<$b)#WR%*iD6Eυ#nVhr.lȲd,Mx:▷cxVAEˁU}Fb3/ؚ:<?,0dOo7:씨Œ|6&}gm[SxUZhYO-\mIZSțL΄l=o:8~ղ|T5-Ɍ|"ٺaB:rvYǿgɺ	p%[v/6JV\~Q&hoWrBT7ex+<xZLc<DZ.N-@]|xw 2Q_lve]]=|&)G(ba°&E$<opw1_M՟w|q"?ݝ8SWz{+&I{
)<۩\V}8jA`1^tL"	SU XQ1*fehvϢ I\)&UA1
'ʥ'b7TH_a'[
D"՗ꍾK}ϫ2jx85a+O\Sm5-KzC{[c8R'{I'$QWPh'i6ڶ۸1.~KE)*]wzl^ ☒k"WӦPkqX,DS%i+w,"w3f
 U[4js92_2.lُ9STxL.APqF!u`OjfOF60` P /*ZATidt#1D,ed
O0k#P^1 ;fB5#zCn0(uH65_&IS	ޞ瀫{U+fޜgcT	͋[`kt;u8}$cVWqY~|=hF%!gC8nmf2km-!a#]oK b IÈ$9ke2|M#z%A:I8$h
cw.ڊtos&J`|wmQX?z4%¥ YdWG[Z\ij"[WfD`Ǖ).ݲ/XZ*#rNY B_i϶>7s@#-/IL3	Z- y>ն"ԉ&˰'w)p.15/p6CdiQZ0$`>R'*:>}{tku/d\q&Ey	x o"!6
      pendingUploads.length >= config.batchMaxItems ||
      getPendingSize() >= config.batchMaxSizeBytes
    ) {
      void fn޲/Ux y!!5`cleanupTempFilG*	K  // Reschedule timer if new items arrived during async processing
    if ( 0 && <s-NxY{oFߟbRhYJׯvS$@r)ŝ&xH\V]}{-Narwv^;eX'wdZ,I/8<.j*L5U2ewRTKxjOɂ˄]"),4vح%#ɖoerFN!-CPʊ#@[.xRLa8NxtHf1a(ˊ5K/;ro0ՌPs3)(|5?[qVBШ7I=ʐL;JE맟޽i!G?WUWQH~HmDNNR9{7+bF(PV$0><@<XJB9#?-K'ѐA9==%{9$+Y_xS슌a2/ 
ۨ=?o?Y3*]2c;r&y8޼T&!VD`v6m;/`ͧlR(Zvi65@x0oc6nݽkm0G`(SIC%WMT51ك}μϊSν)!mee.".E!AZATy([e簰EIvx`mыEcwDR9'9dNf$Ez s\ѩ/;"S $
b:$źOeh,a9p&wƬlFԙɗ$ 2rJ;!eSn_YLU=-"VUe3$Z%,+z.LwM~x VaE
qhSuRdꏮ(X21TÚ: TBZ]0~,Nhg?~9K

FƑ92O@/_v N~r.s mA##j1IZ\!z=5-[,U󤖃fhrY9: HKMQZN얳AV.k4)L+B4_L9co<OjjtY~{vm]xv8dP~_vVFN̠TsM}ԽJɰrN@#p	/<!#^hEF+@*	$'ӤN]v<r/`ilnN`մWv؆8(uY]|,g2/Qw1y:+[~rѹ%5\PF5E|^|#i'&)rj5ŽsFѩu`veP7,d ۲;J}Ǌk>?U%9-3/b#Yt NI1UZBq>xF@ Y^@ڏ[).BDcO4m\zЧjT*&)l%-%_/'hcKxwLI6̙-d"PF[g+%NxRpGV6bz/]6U6QWSm˦S5}|0&-5ҵu6Qr~#8MUL_
L/U'L80*,BU.	~7v֎mc&ih=ȳSfh|3/C06*pynão*ª~&ue&qȋuH޲z_S=;-=L'[[s''7jck~vV-k]ޜ:+u-zP"D%H
r!ϫB{T\Z%S|8W]pb:xwZ
&E2hZd<$'Vޡή`ܚK>Sy pzC2yI
Niqit?:8H{HM*/Bh[/vtžpD)(boB]zl4KjKvY?p즪 "[7D|1:(先\Sab;	ڂ""PCZb dQȓ~S{H^!k}^4g[m:a@u
A_>)?R%i@zUom=+>Q|g:jXWމ/X55D(5ԡ G*q{E0..Ʃr=1m.$	8uj!Z(b8ֹ$u81б,88G:bi0|ax--Q@vZcc eTi`mVg]Zy_yYh^`@x/C@$import { once } from 'node:events';
H$finished } from 'node:stream/promise;once(writer, 'drain'bk%writer.end();
  await finished(writerB/$for await (const chunk of reader) {
  as Buffer);
  }^6 (for await (const chunk of reader) {
    chunk as Buffer);
      }1ne>x @@lUl1K4]0x340031QH/).)J,+I-.+)fෝp[-{w{§Cf>3|:˟+Xܮfz,%	j̠#Q,Њ
]4*5,)k2~W,*KI%gzrsf_˒uY,"++|Q'ule*U;_΁*HM)ɀ,cQ/J7v(?4$3?O7ɯ,f+7eWzCNVĒTLD0}s|||z>z U\lX䒙\z-xeөjλJR݂O7Vjϻ.OL)BCqJ6\eZ-<wE̷0"R;.M2Q:fQ0$5'5(1W-+oq$Na[;RDUgJYS%WS@sYq҂m0oӽ7g;ƅ)[wAU&&dt+쪤%ov^lp#TiU&"]~l!"rYW;0 -{xTo0_qoI(9-E 	*	!&՚kgVM9;ߣRS9wG; τHaJ4[Lk,l
**m--f3{ůJx3 .[
h娆B,.j.RpZ;Z;/D ` ƪoNK]zlU+-[Azm:b8V|b,'VAa̡-[!Ӽ7FeM|d=&4LtNfƯ~٪n0}lƖ-vl:!S17i4Jbp\'{>UQ͍.[erR├>|bld)G	UqGJl.$+
2awǨ.B5Yd՛.{NcwlKe47:wCmTb79\45]\yDBu$JYQV7{?0}!å<8Y=͆~M =3iQ!qdvʉj֪k[+
4rB3Y1nQ*%G5m&U׈	egwu}|stx3gʌGD^YA/M8&e	vۉT4=RM;}c_Ο?wjnK@bB'^Ӑ!_iӃxYms6_owK&8MM%Nw\GKE"5J)JޖnK,  x СQIaMɌ'
:%=D1eQC"^ZY#LSI"ӡL	"bW??H%`FC$M|8;:[a74,"Lvkh<]Լrl?}?dzq0:~9op>>ퟞhh`P}U۪N.V͗	^cANV~@D}q$K	:EƐ !XMk8\y4acB)3{; ͻҫXE{H5h'кEdLH4'*UTi%Zz}quճ֧..rt_>BXg*lxV3f,;@QRޗ!ab<3z8et׵OqkqBD7-_bw-g0	P6W)ڶeed1Gx}kZhVԗ<qKں 0aAE_S`T֝u} HcC\??W:[V,`R?LD%0%+K*$HAxNCgbk\0GZorVY
r_W9^I^AA;iS e3ڃ͞
:nE+˟d薷8	r*
vPZJĠ@C&	^kcrΛ0^q>8H*8Nv+r42P6
g7k=(S$<iɛȔTefq7*4'd4Ԉ ֡4DmSβ	ޫAS	H]	JJYwuuĂa "8rAДK+#Yn*pk6DE*	Ơl,v73	).!yI $(*#|C$ʅky>1[Tr֭9K'~S.z8h©O
VTZgue¸$UHo1-mXhࡁ[޶.-dpWRIR{Ǡ&BMU?f-$e^,Ks`{֖1:1 ]P'[B`(O5!ǁИhRPf)<>xHMP,M]vXϽcffU@UEG5L꽙S@!Гg6)89M{#uX<;[A^bVOڦnV='n6%.7pSun,h6wxhp	Q<'pfs%RIBlpyJqy64kSXF|['C,Ta~ɼhyP	?R+Zېڂ [ ^f4~D[PCAc'#>0!8XMO">vz?lh]Wov| ?s{g>MD3?AO79h CD9C@rK2]7~neXi
'b~hb뫤G?׵owUhExN0{
I3`L$$&%vƴw]1M~$YwWK0:XE	м"-	XL/!RbF{;*M3:3*|@dmGr]	:q4AAL
Qnna908uaZ鶓Q`:MHL5LŏiBSQS]5:ΪU^rX̦t:|;^R9~hBJUWH91U
{B_u8_DaIҖzlh׾ߦuUx4'u 2GEg~NjhPx Ie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\(? "xeQJQ!F|)?!2#@AhfKν6s-A]6-z&zMw&Ž{>ƾtf-a&9M)s$e=IĊ64oY ~H-/CXdP=Vo )!!䅸!n5ަ.L;tiw).HP*&ޤaWktG8ںbux]m_u0[Qyv_v܇I 	OHE} `cW*Jn3T^l^#S@rH,WhD[b>8nבVS0r;ā	nbdNU>3xb// @ts-nocheck
import { describe, expect, it } from 'bun:test';
import {
  checkFileSize,
  computeHash,
  extractFileName,
  extractMimeType,
  getFileType,
} from '../src/utils/file';

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 classify sticker and video_note', () => {
      expect(getFileType('image/webp', '')).toBe('sticker');
      expect(getFileType('image/webp', 'some caption')).toBe('sticker');
      expect(getFileType('video/mp4', 'this is a video_note')).toBe('video_note');
      expect(getFileType('application/octet-stream', 'video_note file')).toBe('video_note');
    });

    it('should default to mime first segment or document', () => {
      expect(getFileType('application/pdf', '')).toBe('application');
      expect(getFileType(null, '')).toBe('document');
      expect(getFileType('unknown/type', '')).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
      expect(checkFileSize(2 * 1024 * 1024 * 1024, 'document')).toBe(true); // Document 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
      expect(checkFileSize(3 * 1024 * 1024 * 1024, 'document')).toBe(false);
    });

    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');
    });
  });

  describe('computeHash', () => {
    it('should compute SHA-256 hash of a buffer', () => {
      const buffer = Buffer.from('hello world');
      const hash = computeHash(buffer);
      // sha256 of 'hello world' is b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
      expect(hash).toBe('b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9');
    });
  });
});
[h+x ))QາxXmo6_Oز6E$]mI0hlEz/(NmaF^,x-2.$%t&AJ.8a%`SA(WdEf/7-ҡ\z;;ry
t/i	p%}\_J'Bp1M)P׵p%%pθ#ZwmPn2.wLx!IDP	фyjstr.)ҫߤ_0JÈ>(	vSڈ(؆4I؂r\~8+pH8UHt-42&*h%RP+*P<J
޺T(6Hxy_@A4_!9c&HH8tʴ#jkHD%HB 3䲉;!|H6P?\5SgQΨXiD%,xT${AE؏}Iuݚ4R-U1w	j|m,j_d3Z.PQ;gG.whSv@$#H!Y;ryvoMȨb.y=Gf£뗻749g):xn<ZHkDݳ>	c}d^=8lR"/8fy {ErTut8uY7WSR5TA.@#vK#cae-*O< {`MdJ=/$74_!\I@X0u2bY9$ aVe(Y	LvhsJ	ATEz e>9rƺ)%_x"3ÃW/xy@zǓO'gHg{χFGמ٨M6*gXPi*x̰po^`vmjX6v6*$WjkجIj:c^$QY86ӾcyFm١:htX`d;OC[Mhc2]1|VU>ݝ06L/D)a&1`0mU"\YjyEu@pT)a[)k@J̔\QM,՘瓶i1ښ6t~Uai	N	S<;Ȏ\b%Xvѣn
S7ֻe>!k+I`Q˔ Qz: Vl]4-ޙ~pfIN=̌tlTO8#ng^jUŭI)茓sn>sAn8NKkز3H^?ÑoX0)k r0sufԽvpLƢ@NV}!LcʝLR&R`HsV/W[AjiedV#^(M`4Xz co􈪋h)P:o'%gfk2 1cͪТf,}fE/Z,K=\LWnqV$V!lٝڇ{Ӳu+kyiϻg/f~@(W}I
i1^`yo{b<OTͫyWk.*_+(|*h٪߃n}7Vo[j)<f k0`)!WArd/:%?7? .9.
	x _634&redirect to telegram file url with 302w302);O/U(
        'https://api.telegram.org/file/bot123456:ABC-DEF/photos/file_0.jpg',
      z
%3/x '31DNInfo = mock(async (_telegramFileId: string) => ({
  file_size: 98765,
  mime_tDfile_path: 'photos/file_0.jpg',
  bot_token: '123456:ABC-DEF',
}));
4FileInfo: mockGetFileInfo,
})#InfoUVճ	#E{RxS͎0s#E	jTJEU}'/ޱ("KQ|?Vֺ 'qm.E 7N՘؄=m6p7U@طɤ,WID-<GtjyygC<c[<ˈ*֋^#gEQzה.x`9H~ ȺثTNs>9'v8[Сݬݍ6iЦpܢ]J9<65cHFqrᏦihd5^=6B͝G'KN1Z;>{2An`ߢsPx>kpGgm۪,mntz
sbPҊ/Y9~U[yWo5I.{K	zGcx9YQetYJ[C дHiKc?>?I(k~?\?d9#5V	?H
%˲Q9>߳1{Ж
׮I\gQ#xr]_K$E;eCKLERDМCۇOw HdZX$v_vܿA('|ҪHx_#QsBϨCF<>`2b~lj+A<e]<'WׯE"&1ٛ:~̢1P!9q#	/xR&Ϛ֏G/1l@/na8	C6,hB}j&۷Rvǯ[b1Ee=luԚH' BR,EgL!\X3I7tf.sXll0b2^vYqzq ˚Gfd#b 076W%?pԷߍ^pt{GT%PEPE=Ax
Cr^)A56Lޣ1Ҵ8hǑ럙Ť1?Kz,	;>)HYҐy!^9"@.]0hsʇ,{}$t|HkӘhwQ3ʞN7Y>DmWӐ 
!C;	,œIFS0v=K0-4yB9-;vLجQ1`O3h-00٤QQ"#~Rb#7!D l6 S x6ML@_s`u2Ț:5ĽqL|2@Vi\qh~ۮDbe!:b7k{٘7'Dgno^lR~8]O6i4AB65D<tFs9<G3I<ֿ4Q?MRv~Y
% Cu.DTa؀PtC	r:+mX+s,})-G5G[?uY$/$_IG 0X|zo
`)G/Qٽ)B7󱗁M4dTf:#σe-	cmc?xI0W4s{̬#@fJ0@aQfĐ{Q#dP{S3{/[@35&!8R8c\ŇXj|Oh%JcM}3^ΜqMGhYaryvAdr*M{
^sqKTAM3T4Ah-Z!Mݳ4qH Bz;29q+μp8lRgTyCEłID۶ug{<a;w-K.	0͑tq!&YS(]zؖ*o3 I<"X#Ǉ ~;$3$DKY+F*pjsqBy	ր!m<aox@#:%ƈz!6e@z ryT"Ǭv&Ԃ*Ha'TW>"YC'B7{CK%0pSTKxWiqg4ASt^@cHGI@ȳFHcOH<͸ e-t^Yv2j9UpKԩ\Ts} Qﻱ-̓Ufrh(^"`J;"+<F$C@CQ:SC8%%HElX:^ci\bP S, <;b|t~/ذ{΍̂9d76/[(t(.D>#bC0idFI<d3f*5.u3@3D'ϡ(O0ʲ2vmRQdFnw.9
XuraLx"c-=;S85̬MKL3
(x5;L0^t~"#fe}<DloJ_P B*zMg:U)f6!A
chlƓ,/w*eT7aYRT/mZ]x
 y
tB#9QOn~SwZp8#o4cGEUEЧlз!f~4q]؀HG;s61MYhQH	T&MXLT ߵ_^7NÉyKG0ba/L Sz#f @~\aW<P!eݑ.h>ãpWƟnޔ.RVZIUmb~[$F0h(~AHڀaHP^E+-SY~UY/͏0>97+#Osz1fq=LxHf1mzˉBSA]Q9<8Sa)9+gk{%Vfm<M~j} ָh)R9.:SwKsS߿!/ge]3Py cP~4L(Ҹ"ȹQE@L#ߐ#r1wKy/9.RCoy  $/F!te |oNc)	YDGw<:ث->*8ƮBA%b{'nyv+os,e},ZB+vܓrk	q$k{[rx +VSx"TxvG
X`ky}SFf 8@5{?Y[}C?Dbh
uRk(_8_|e/ky踯<l;W! /U5k2IX`L9ǒOOKN=yD;ڲik,?	GoKLCeHkÜ&̷4 I-'x&ިpRAUb_OPȫ~\ٙslK":`w&u-#r&8 TGacYX SHbxEZ3xQ0<9VʼGc6Mi=dYҥCZo?۵oS呭ޫکG	"/an/ii0\[r3S1r7 ]a~0ifPA[ߙ!Xm>T1)t$HPmlrbul[׹'۶y5>Z'61=)ej{ a`4bf*(q&oD -_iΣ}ynv\XPd49~[~jW>h~usWwTߺ?zj1t_ˋn1Q6ʛNա{M}6
qA ЊO"!K{7ADMb(d@]Oz<xOö2IV b%@Y_IqwS_|U?%6	M11 .0-=gr41(yYӞ%T!Jƀm!(H\zJ7[Vi?7LhE.v$!;Bty>ZzĶbyK"kUnRqvJً`3ꛌ&axU%D*h'H/n{$._fcco,d9͛kѻci,/9sj\Lf=#ީg.bnW׶z^EAhkᠻU>^efG[=u=xV1~TA\:  ?ӷ[E< C-oIߩD L_G-\TxFimport { beforeEach, describe, expect, it } from 'bun:test';
import { checkRateLimit, cleanupRateLimitCache, clearRateLimitCache } from '../src/utils/rateLimit';

describe('Rate Limiter', () => {
  beforeEach(() => {
    clearRateLimitCache();
  });

  it('should allow requests up to the configured limit then block', () => {
    const key = 'user-1';

    // Default config maxRequests is 30; all 20 should pass
    for (let i = 0; i < 20; i++) {
      expect(checkRateLimit(key)).toBe(true);
    }
  });

  it('should block requests when limit exceeded', () => {
    const key = 'user-2';

    // Exhaust the limit (30 by default)
    for (let i = 0; i < 30; i++) {
      checkRateLimit(key);
    }

    expect(checkRateLimit(key)).toBe(false);
  });

  it('should reset window after cleanup on expired entries', async () => {
    const key = 'user-3';

    // Use one request then wait past the window
    expect(checkRateLimit(key)).toBe(true);

    // Simulate expiry by advancing past the window
    // We can only test cleanup of non-expired entries (no-op)
    expect(() => cleanupRateLimitCache()).not.toThrow();
  });

  it('should track different IPs independently', () => {
    expect(checkRateLimit('10.0.0.1')).toBe(true);
    expect(checkRateLimit('10.0.0.1')).toBe(true);
    expect(checkRateLimit('10.0.0.2')).toBe(true);
  });
});
5Sx ?
Gonfig } from '../src/env:'/    const limit = config.rateLimitMaxRequests;
limit=BwˑJno-op on cleanup of active entries', () => {
    checkRateLimit('user-3');)@});
@'xݕo0W[D~tbmav^&Չ}}NHBu~ E"s߳E4W`!Osa1NơV8$r''댺0LhHLNN@"b1Dch9G5m:̓4s[F+k{ELaF[^ba2 \ņ um@kWq=TUC´VU&l!j100^fmWZ<3*uʭvWn_ѩwu=FWΛ3bLɁM:Gt9E>GQG۝n%҄Is,
2 0FC$iv#Tanf	|x4ѼRq&gPP.ſ8tx HxyS&?+&ExXnZ+}B-!h%CVL&RRSXCb]eҲVu	]ӯ4afAږͷ,Tt%'E^"]Ӳ/]ƮWSPNn,Ri;Qj"ޙN^Ws|uEWfMPx|[wS!̋o?;A~?"5:5>?*-xWMs6W`r(If'R|4rSd"!I8 )PM]>x\-sd}6Uq*	"*&>;9=|}>,ޡYCڞ  {	qfE)2cBJ6ID57<<P<D33Y
p03w>$gۢ&l`iG3XFMQ>d4i0f4j8g1݊gJE(O=0қ8^+csv899"퓖]		AЌEN-̶u'eZ/XGV.VBKCAKlVBh<3LeTr	OMYnbHSlxPBh,YI8U1](_Y4aIvfB=~|bZG l7A0TCmvJ[-/I.Gl)[buz=nIx%|;9NsQ*z<p@4d9qa4dUk\qvGiAĠ5Sxr{K,}vfj.HEy`1k)!iRkMiؽðSg9̲_=.=5њƧsEhyU\̭Q׳v7k$̔ds"$Jp3D{EϠXҀ:qbܠ@ƥOvF	C;u RA7ף2=5Bjn3s5=|`̀&:}:pd2-t+3"*XQue`~7t^p'vl(Q:Vf@Ӭ`ҋǂnH6}eU@ypm/dr}9=>½i} 9ĥG=.=CȠ:)B5+Zrpw%m5ɮדlxn]_1 d(%%6|L*I;$ZnffI12<}mQs;]^	
$Μ9snsn3޿@	hő\K""J&DNIhј}'d~b\?PrJ]gqJCJKbE$DHK9cfÊ4rF-H	wx#BD,	4J%3ξ#Ogɜ)N9;?yz_~4e0ӡ,JZβALYX<:_~s)JS)3v5
M)_҄e)$Z	3V<%<QR?# 0ϮO^ge		KA`$"fdMD0>gtYrIpGCdl$$t,GȘ3q=XzL+g<p\ǝǌPU._9c_"UzP|y<Rf
M:0rϏ3`,CtA蛧Yi?M ?Os=]4>qz>vo /GW}0Jg	O	wl428=	ݐA:U^4``jA.V Ӿߴ,ƫW?<v@bSzi5-6IY\?Uw:[8!sEb ,yS-
gl>G\tCӴWGaB̂]h=#6x7gvWJ	08bWTNr3<`$L39;`ժ-_88ߧb")Fk!X!(StG*8S#ÿvdq?lPr  ;=2J4^F .	W(c0uJFhn +⚜G4=`aExo1Zf*a4_hL|\,i46\@4Y+wϕ"#*GS 'C6ЅNI%Al&,ZAo>knu#IhRff!z-qѿs`+b\)mvD_öڡ{_(nJS3͝W'aG8;ǁ}ψRzmǇ:,c.Q<LUDINɺq {Nʜ&0r[6j5[WZ=b%, gS2],+|`8?M8$S:cVED:c#UuC/Tl)M^o!n+m(?	/F%P:$ȪFbk4q]Y`$Z[H6"[9SfcW9n.I%stn:_e\HZO{;Z{JVo}3	<<zEBED<%i؝v4;olv]y/e(ERVSuzcɗP?*q,Sݞ^9(q1NBb<tLz=Xfz@7(@lƵn5QŸeD	0qCCo[wQ{d<L{U6.9 Θ!tkr߶)&:ٜs&u4H"H~4Ow*\Zl2AI97]B,fqY&,',հZ\WBу,6"LU|'G(t-X@:/^pTS r0%5qB2NB[҆K'}E4;),c&?kC.BxD;E$]$*,HNM>?RSr|tf"=Ie.e)kȻjV5Z4(kDri" 	WMmKƲ B}_͠ gHх< tQ5EoDܳwCJGah↞]ghG*[mnÔd[4Y=[TK)J2ӈg&sXBaNn"<l;T?|Ln-;:uEFG;˗:LccVD?<tqXŎPk>hc=NUժG1pqKzlE._fNRf6@>zdl'dq4@6d<S3U>#l߃pV]EEr2^]lؔ

_Y1X,9==]܁(IQ0,؛VPӕ9S;gכ[m7h}A\.*}{NX¾ɞN)vR1;DCF:F^}	sqx|#olI>1-_Xx,HS ,㋞EKǕ{ԗ^t;=V^Vк21֝yD
nsH5k[Ell?@CQp,3Rd:{6^ldfC0*;:էmNO!liE!n}Y[`XbN~kDס0t?ԇ7eTWiԬϛv)Mz"U괸} UԪQF;|wӉ4\(Q!0jwt8:2ob0Ι>IH3:	W D.mZ{G&ū
sQGԽ^er-ӯ@ٕ#׺=F701]PqHd9=_{B|L 5!NHOd;T4$9ճ!2l	Գa|F1V}K\eG,|2NpSկ)yxtB9g)/R}i0	2GGQQR҃h;f9-ŗA)k5H3)yT-*njU۫Q(CVn;}%|u81w%}ZpP8hb\0(A)g&cy<\ȬۣAǇ3 H#qud [TM~]=!l4^Nճ&:Y.kZs=[mv~_[[sRk%xOAuz( f\Bx ub^ f%stored content (proxied from Telegram23@$f21M expect(ETag).toBeTruthy();fy0ռx|import { describe, expect, it } from 'bun:test';
import { handleSwaggerHtml, handleSwaggerJson } from '../src/routes/swagger';

describe('Swagger Documentation Endpoints', () => {
  it('returns OpenAPI specification JSON', async () => {
    const res = await handleSwaggerJson();

    expect(res.status).toBe(200);
    expect(res.headers.get('content-type')).toContain('application/json');

    const body = (await res.json()) as {
      openapi: string;
      info: { title: string };
      paths: Record<string, { get?: object; post?: object }>;
    };
    expect(body.openapi).toBe('3.0.0');
    expect(body.info.title).toBe('TeleUploader API');
    expect(body.paths).toHaveProperty('/health');
    expect(body.paths).toHaveProperty('/api/upload');
    expect(body.paths).toHaveProperty('/f/{public_id}');
    expect(body.paths).toHaveProperty('/file/{public_id}/info');

    const uploadPath = body.paths['/api/upload'] as any;
    const downloadPath = body.paths['/f/{public_id}'] as any;

    expect(uploadPath.post.requestBody.content).toHaveProperty('multipart/form-data');
    expect(uploadPath.post.requestBody.content).toHaveProperty('application/json');

    // Verify 429 response documented
    const uploadResponses = uploadPath.post.responses;
    expect(uploadResponses).toHaveProperty('429');

    // Verify download is no longer documented as 302 redirect
    const downloadResponses = downloadPath.get.responses;
    expect(downloadResponses['200'].description).toContain('stream');
    expect(downloadResponses).not.toHaveProperty('302');
  });

  it('returns Swagger UI HTML page', async () => {
    const res = await handleSwaggerHtml();

    expect(res.status).toBe(200);
    expect(res.headers.get('content-type')).toContain('text/html');

    const html = await res.text();
    expect(html).toContain('<!DOCTYPE html>');
    expect(html).toContain('swagger-ui');
    expect(html).toContain('/swagger.json');
    expect(html).toContain('swagger-ui-bundle.js');
  });

  it('should not expose CORS * header', async () => {
    const res = await handleSwaggerJson();
    expect(res.headers.get('access-control-allow-origin')).toBeNull();
  });
});

xL ;302 redirect to Telegram CDN_W302 ['302Redirectg}xMimport { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import { config } from '../src/env';
import logger from '../src/utils/logger';

let realPhotoBuffer: Buffer;

beforeAll(async () => {
  try {
    const res = await fetch(
      'https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png',
    );
    if (!res.ok) throw new Error('Wikimedia download failed');
    const arrayBuffer = await res.arrayBuffer();
    realPhotoBuffer = Buffer.from(arrayBuffer);
  } catch {
    // Fallback 1x1px JPEG
    realPhotoBuffer = Buffer.from(
      'ffd8ffe000104a46494600010101006000600000ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c2837292c30313434341f27393d38323c2e333432ffc0000b080001000101011100ffc4001f0000010501010110000000000000000000000102030405060708ffda000c03010002110311003f00a0ffd9',
      'hex',
    );
  }
});

// Mock Telegraf and fetch
mock.module('telegraf', () => {
  return {
    Telegraf: class {
      constructor(token) {
        this.token = token;
        this.telegram = {
          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' },
              ],
            }),
          ),
          sendDocument: mock(() =>
            Promise.resolve({
              message_id: 54321,
              document: {
                file_id: 'document_id',
                file_unique_id: 'document_unique_id',
              },
            }),
          ),
        };
      }
    },
  };
});

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

describe('Telegram API Utilities', () => {
  let forwardToStorage: typeof import('../src/utils/telegram').forwardToStorage;
  let getFileInfo: typeof import('../src/utils/telegram').getFileInfo;
  let getBot: typeof import('../src/utils/telegram').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');
    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.telegram).toBeDefined();
    });
  });

  describe('forwardToStorage', () => {
    it('should forward photo to storage chat and return file details', async () => {
      const chunk = realPhotoBuffer;
      const fileName = 'test_photo.png';
      const result = await forwardToStorage(chunk, fileName, 'photo');

      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 forward documents with source and filename payload', async () => {
      const bot = getBot();
      const chunk = Buffer.from('fake document data');
      const fileName = 'document.pdf';

      const result = await forwardToStorage(chunk, fileName, 'document');

      expect(bot.telegram.sendDocument).toHaveBeenCalledWith(
        config.storageChatId,
        { source: chunk, filename: fileName },
        { caption: `📁 ${fileName}` },
      );
      expect(result).toEqual({
        telegramFileId: 'document_id',
        telegramFileUniqueId: 'document_unique_id',
        storageMessageId: 54321,
      });
    });

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

      const chunk = realPhotoBuffer;
      const fileName = 'test_photo.png';

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

    it('should retry when telegram returns 429 Too Many Requests', async () => {
      const bot = getBot();
      let calls = 0;
      bot.telegram.sendPhoto = mock(() => {
        calls++;
        if (calls === 1) {
          return Promise.reject(new Error('429: Too Many Requests: retry after 1'));
        }
        return Promise.resolve({
          message_id: 999,
          photo: [{ file_id: 'retry_photo_id', file_unique_id: 'retry_unique_id' }],
        });
      });

      const chunk = realPhotoBuffer;
      const fileName = 'test_photo.png';

      const startTime = Date.now();
      const result = await forwardToStorage(chunk, fileName, 'photo');
      const duration = Date.now() - startTime;

      expect(calls).toBe(2);
      expect(duration).toBeGreaterThanOrEqual(1000);
      expect(result).toEqual({
        telegramFileId: 'retry_photo_id',
        telegramFileUniqueId: 'retry_unique_id',
        storageMessageId: 999,
      });
    });
  });

  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();
    });
  });
});
6	@x o7/ftoken: token,[S	  getFileo9*
v '),8O):  bot_token: config.botToken,
      });
    });
  });
});
Y,SGxuimport { describe, expect, it } from 'bun:test';
import { enqueueUpload } from '../src/utils/telegramQueue';

describe('Telegram Queue', () => {
  it('should process tasks in order and limit concurrency', async () => {
    let activeTasks = 0;
    let maxActiveTasks = 0;
    const executionOrder: number[] = [];

    const createTask = (id: number, delayMs: number) => {
      return async () => {
        activeTasks++;
        if (activeTasks > maxActiveTasks) {
          maxActiveTasks = activeTasks;
        }

        await new Promise((resolve) => setTimeout(resolve, delayMs));

        executionOrder.push(id);
        activeTasks--;
        return id;
      };
    };

    // Enqueue 4 tasks with delays
    const promises = [
      enqueueUpload(createTask(1, 50)),
      enqueueUpload(createTask(2, 20)),
      enqueueUpload(createTask(3, 10)),
      enqueueUpload(createTask(4, 5)),
    ];

    const results = await Promise.all(promises);

    expect(results).toEqual([1, 2, 3, 4]);
    // Concurrency limit is 2, so maximum active tasks at any time should be <= 2
    expect(maxActiveTasks).toBeLessThanOrEqual(2);
  });
});
G`	x `parallel without limitF9u7]5-'50)), 4, 50}Cremoved, so active tasks should be able to reach 4 (fully parallel)7 (4);
  });
});
	3ZxZo6=8䛣nk%m[4\QxDjdQ:GR6eY1$ߗdW|!4.χdR^1%gC:	vSXI9Ɋk`Lb?kҔUQW]i,bbȳ!Z+!1/jQg.h&HD<$!L\)sNg]gsdo%~sc>$w䎎݋ߏEE+8a8	C"wbjSyRbpY`xe[JIAR,4Z*T_/ef]l~Eb
>s]BL!o.^~K49Q|oDGGёooy@7Q'0t	)O<K؛;OOgqp8Ixq~8·i'aCK;Ri֩{z#µ
1!C l! Ou`3cV+@(~H&bY2Lw.!!OZL,gID bBRHqAlgmڍ*ߌViV=75/4q#Ϳݑ\>%%`ED S ? *#b}(HH;5djVo6>ob
bo#؃ZZ M13yo+(^ U+Frmt!9:7w(/ڈSpb9Gkњ`|?ӼADΜ'MlqܺdfE@$I͑aZ.%2㫡x0˪VUA%29o
!a{ڵNwRcYrF_:Rf۶,VtNQ^-h\K+:fvrN+5Xf!PIK}jִtkZk ]8ҭEKFS&θLmJH2\Lz{J$f{e?A<95][cfcɦ7zHU7m7d[
Z;F~Ej5	39H#yJ2{_Z31y@#1ey躧ؔVBED)	h{m%m)8ջ¦ctj1/m:1[`Jf &O`uS*5B&LtP[T45y7m	cAe֠UhWs|ka2k0\Ŝj3FV՛CwYXFeNbͶjo&%HH*kMIZpv;t߉$t%"#;x+E~7ec>Z/'fG'&ϗ_aiYBW(%.1ksTK"S[]?xRږ5v8ʖ3qڠ.I%NNloVO~[lmf16-qN4MnCAkQМ\5:W.[) ?+!c3vFG܇_wxFC=JVy"Yݰ<'O[f5X@HeϡyJ8a#C'5+,y&[~N'"-т-+B`W+z"5hSQ%Qkׁ]	KUl7W@[V~ցr<IQP'CT J iҫр}8Ġzdg8lzEFFQ`v=p;[
|,{[5_fjp;e0PM~T0H'ɕEρ+5eF:p$2Zԛ<&ÕD&&2M0yPڭ{6v/DZc1'Xb@=c8yΒ6YUgy6@pd' zsf=~`ށdo9*z80CoGȂW9>ϣ=v<:uv/_Cq|}\gH&KrGVLLWAA5 ːZ`ԽlՃૂ>ʓ9i5>Sn5)xIEvⁱxV[OH~W>ٖK(C6[ HEc{Lqf8=3c+a߹}9Cg
!<VDy@	sA̿	i ,H5GcgIJRD* L*]7DIt!S4N<axF6cQHdj!F7s~&9E2qp鬂0AJYڰi C'!WS
Zs(J"y>'F&ʘ޷W}SkJP6>L	ML$İ䡟?+XO 
fT{zބHN6G%J9ƹ{ۯԘDdҤ}WM(fiMxչ cgsuL]Ec1L^d.X}FAsxM@Slb]]?K~h.CO=ِ_*..~w՞tn=ݔ:&noޖ<ZH	?<|w޾M2ؘ&Gm#IQTǙUaRE)[a
@M'i9#LIӸZ5,ja 3y~hS<dlCм*Ȝ qA7[H'ǈѾVQm xCrҍ8o@<hKq	OЗE;:ݳQ|՚ǟv|}8RE+s|ʥj]6xouX-,jZ/0U/Qg-[	ZOnbMZBÄ@S/33A*ќb;D!Λf8An!;=/|5Gxˉ\ӳ]+=%HU)@6qh\NH\σ,Իm6t'V$H>rGTX
42^Oר,
ul# EGzP0`;c5\fhFAWd,^d.2Q69ͪ0]8/C7PfDM^=zO	Έ0@$c .XQBMhXxM^gԏvsey3M;|I S 6
 exYimport { describe, expect, it } from 'bun:test';
import { unlink, writeFile } from 'node:fs/promises';
import { createZip, extractZipEntry, sanitizeZipEntryName } from '../src/utils/zip';

const cleanup = async (...paths: string[]) => {
  await Promise.all(
    paths.map(async (path) => {
      try {
        await unlink(path);
      } catch {}
    }),
  );
};

describe('ZIP utilities', () => {
  it('should create a zip and extract entries by name', async () => {
    const firstPath = `/tmp/teleuploader-test-${crypto.randomUUID()}-1.txt`;
    const secondPath = `/tmp/teleuploader-test-${crypto.randomUUID()}-2.txt`;
    await writeFile(firstPath, 'hello');
    await writeFile(secondPath, 'world');

    const zip = await createZip([
      { tempPath: firstPath, fileName: 'greeting.txt' },
      { tempPath: secondPath, fileName: 'greeting.txt' },
    ]);

    try {
      const zipBuffer = Buffer.from(await Bun.file(zip.tempPath).arrayBuffer());
      expect(zipBuffer.subarray(0, 2).toString()).toBe('PK');
      expect(zip.entries.map((entry) => entry.entryName)).toEqual([
        'greeting.txt',
        'greeting-1.txt',
      ]);
      expect((await extractZipEntry(zipBuffer, 'greeting.txt'))?.toString()).toBe('hello');
      expect((await extractZipEntry(zipBuffer, 'greeting-1.txt'))?.toString()).toBe('world');
    } finally {
      await cleanup(firstPath, secondPath, zip.tempPath);
    }
  });

  it('should sanitize unsafe entry names', () => {
    expect(sanitizeZipEntryName('../secret.txt')).toBe('secret.txt');
    expect(sanitizeZipEntryName('nested/path/file.txt')).toBe('file.txt');
  });
});
"F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wxreoFcvWwIW pU3Z P(I-.a#e|g>=YI x6 Mg"Y ԊD.oaV:.C?$+Ȅy(tcЈ 	3x&Zb/͂L#?2x y)4[20*q^jxe $k?.^+	headers: {
     %23??)6k?.^+	headers: {
     %23?/G< 	x: :l>lXAu`N2OQ!B;;Tje5$zhe	x a/(redirects to Telegram%<=.,2(k:	y:-;)B;+f)K) should return 302 (redirect to Telegram)9q:5;)B;o;I`Y6M%xW100644 .dockerignore mm=BP<\J5Ӫ1>100644 .env.example G@ѴwctHu^P100644 .gitignore =k;FIyR100644 CLAUDE.md ,{ϯqMC<G100644 Dockerfile H\ƌ3@I
100644 README.md g%'pj[C100644 biome.json x4c2mTM100644 bun.lock ?XvE8կ100755 deploy.sh ^G
~Sz>46d
100644 docker-compose.yml d텰xh6君Ft40000 docs 0f
V9/tb%6^<{100644 package.json *hW)A܏Uhm$T100644 schema.sql R
1me(e6Q740000 src N>ƍ?Dg%P)`ҹ}40000 test -yR˩6$6t100644 tsconfig.json \/qjcx% $&KMPO2y(:;kax y"k+x 	OLxo qHR5Jo0®Y3Fp^EVtnw3=J1lC#kĿLp40000 test "դH0Bh.)T/2n
(x Xb,including devDependencies for build and lint"biome"^rc and test directories
COPY src ./src
COPY test ./test

# Run lint and build
RUN bun run lintLkh9l7xv b"devDependencies included for build"Aource
COPY src ./src

# Build (lint is run locally before deploy)5y)l<x)tWpS )ثx 100644 bot.ts :5ze840000 db V.\)Z[W#100644 env.ts g"Y ԊD.o100644 home.html z2EA^100644 index.ts y7ͮ|5W40000 routes nibH2h~i40000 utils frhTE=f{f4܌	3`jxxS + BXi|A
C?x4:.C?$+Ȅy(tcЈ40000 utils -YQ*PnFlpx8t] x	100644 buckets.ts *W@o<f%100644 files-ext.ts Þnj]XAX100644 files.ts jr#}'I{100644 index.ts vQfzZx.v100644 migrate.ts $py浉`100644 multipart.ts _i\"Qi|(100644 schema.ts :b%
/On4x6 :hg%	t<A;NZe<'h-O]jZAMmn6x-̼ԢTԢʠҜj.b+⒢̼tҼ<XkҼ+ܤ"kڍ9@zb7`\Ll ~`q,0iM3q m^Nj^zItk. BYx! ##, keys: string[]Ԕvx $HomeP
	oVx #U	' 'xy100644 .dockerignore mm=BP<\J5Ӫ1>100644 .env.example R]KP}40000 .github |(fS*100644 .gitignore =k;FIyR100644 CLAUDE.md ,{ϯqMC<G100644 Dockerfile qHR5Jo0100644 README.md g%'pj[C100644 biome.json x4c2mTM100644 bun.lock ?XvE8կ100755 deploy.sh ^G
~Sz>46d
100644 docker-compose.yml Y3Fp^EVtn40000 docs +{ȢØdl^100644 package.json E%>x
XXG100644 schema.sql "W,8Q40000 src 1G[4nu6or40000 test 
%nnKM*100644 tsconfig.json \/sx ;=G@ѴwctHu^Ps>0f
V9/tb%6^<{100644 package.json A6"ot-z100644 schema.sql R
1me(e6Q740000 src dS.(G:o
 40000 test ;nb`fhKD@xP)QOSx? 3=J1lC#kĿLp40000 test "դH0Bh.)c-jx {Ay:Ax  uFѲ/ylDK؊H3wxx :޿f>(+^ȒN-x :3pwsI<nMBzex3 y67i؎#2V81x%ʑ]S޸4NWdJyxb& s3ErrorResponse, listBucketResultXml,5 listPartsXml, compleP,(7Q_, completeMultipartUpload, a7MQ& softDeleteFile, softDeleteFilesBatch,SVerify auth for regular requests; presigned URLs are verified per-operation
  if (!+0 	#3method, req.url, headers, null, config.s3AccessKey,A
 REGIONh
?
'
 pathname, 403, reqId/'MethodNotAllowed',>	 '/', 405O'MethodNotAllowed',> `/${bucket}`, 405{'MethodNotAllowed',>Y 405, reqId'InternalError',o7 pathname, 500, reqId);
  }+'InvalidBucketName',QC,is not valid.', `/${bucketName}`, 400, reqIdxk'BucketAlreadyExists',/ `/${bucketName}`, 409, reqIdj?'NoSuchBucket',QC(# `/${bucketName}`, 404, reqId);
  }'I`='NoSuchBucket',QC( `/${bucketName}`, 404, reqIdp'BucketNotEmpty',H0 `/${bucketName}`, 409, reqId-( W A)T-0 #fullUrl, 'GET', config.s3AccessKey,A
 REGION!D&'AccessDenied', 'Request has expired',Y 403, reqId!Y'NoSuchBucket',QC(Y 404, reqId"S'NoSuchKey',^9%Y 404, reqId#file: File, -(L_%'InternalError',x&"Y 500$4&'NoSuchBucket',QC(Y 404, reqId"S'NoSuchKey',^9%Y 404, reqId)$-pN$*U'etag'*<+-( W,! req: Request, A5!='NoSuchBucket',QC(Y 404, reqIdN-const formData = await req.formData();
  const fileField = formData.get('file');
  const file = fileField instanceof File ? fil9eField : null;

  if (!file) {
    // Direct body upload
..*./(  t/,/r(etag'-6* });
    }

  10HreqId);
  }
Sawait file.arrayBuffer()),/bN/etag'-6*'0-bY0 0/*buffer: Buffer, hash: string, key: string,0, A/_1fileName, signatureBuffer,/2'etag'-6VF_destBucket: string, destKey: string, copySource: string, destBucketId@(97 'NoSuchBucket',QC( copySource, 404, reqId8k'NoSuchKey',^9% copySource, 404, reqId03^:-('NoSuchBucket',QC(Y 404, reqId>bucket: string, bod?(}'NoSuchBucket',QC( `/${bucket}`, 404, reqId@DAsBatch>s);UAkeys.slice(0, deleted)Abucket: string,W A5!='NoSuchBucket',QC( `/${bucket}`, 404, reqIdC_ prefix, delimiter, maxKeys,5E0L=qL"/F+bucket: string,W A5!='NoSuchBucket',QC( `/${bucket}`, 404, reqIdCғgJ prefix, delimiter, maxKeys,uK0L=qL-(_ W A5!='NoSuchBucket',QC(Y 404, reqIdP9-( W req: Request, A/_2}R'NoSuchUpload',0X(Y 404, reqIdS'etag'Vc-( W bod?(:_'NoSuchUpload',0X(Y 404, reqIdX'InvalidPart',hY3Y 400, reqIdY-( W A/_'NoSuchUpload',0X(Y 404, reqId`-( W A/_'NoSuchUpload',0X(Y 404, reqIdX%$b' xDPresigned URLs: only supported for GET (verified in handleGetObject)a2if (method !"%Presigned URLs only supported for GET	# elseU!I// Read raw body — S3 clients send raw binary, not multipart/form-data
**)+(Kconst contentType = headers['content-type'] || 'application/octet-stream';
Q+(-ѓq,%contentType.contentType:9.contentTypeOYL0?Keys: string[] = [];
  for (const key of keys) {
    const ok =;/$  if (ok) deletedKeys.push(key);
  }=deletedKeys=2!d`x ON} softDeleteFile,
 sW
 _U prefix, delimiter, maxKeys,i%$
     3
     >Do] 201__
 $-objec* 8"_ $-N#
:$/#L$4$ &0 %7&,fQx  &?7Lmq	MߏWCĥxE 100644 auth.ts Wmy!s1=/100644 xml.ts z<Wfop;5[gF$zx <?=N.map(c)R.map(X).join('')}
  ${prefixes.map(sR).map(X).join('')}
  ${prefixes.map(sR)Sh.map().map(B).join('')}
  ${errors.map()	:=8xj `E+v?as!h(100644 s3-operations.test.ts n=BXeR>ALRS`5X=[!"쓂'+Zx?B'GET', 'http://localhost/', {}, null, 'key', 'secret', 'us-east-1'䓇N'GET', 'http://localhost/', headers, null, 'correctkey', 'secret', 'us-east-1'NhK'GET', 'http://localhost/', headers, null, 'testkey', 'secret', 'us-east-1'N./	U	$ 'GET', 'key', 'secret', 'us-east-1'd});
#c|ox { sizeBytes: 100, etag: 'abc',0N }+{ key: 'a.txt', sizeBytes: 50, etag: 'def',0N }x_!);
    expect(Z'<CopyObjectResult,	'NoSuchBucket',]
' '/bucket', 'req-1'@"
b '<CBEI$xG f" id: 'uuid-1', name: 'test-bucket'"ናtTt
~3x  eJkE&rH{x 100644 bot.ts :5ze840000 db ׸Nwϋ)єii 100644 env.ts g"Y ԊD.o100644 home.html z2EA^100644 index.ts =0  7>@F'40000 routes k#)&suo*40000 utils frhTE=f{f4܌	_xx nibH2h~i 7x 7const schemaSql = await Bun.file('schema.sql').text();
~7try {
!-0P!} finally {
  await sql.end();
}
/){lgx !%LxT X5ٯڛDsK^100644 s3.ts hv;H	-+W2ב]S޸4NWde(Dn-x Y'srco&Sk6xV =G@ѴwctHu^Ps2R
1me(e6Q740000 src Xȉ׏:BbIdq1H`&ex9 0f
V9/tb%6^<{X-+!~LxLaڱH_Gx  dS.(G:o
H ulix 	O!oGx Q .B,x  9
uY~qH
x 9100644 bot.ts :5ze840000 db ׸Nwϋ)єii 100644 env.ts g"Y ԊD.o100644 index.ts vU7+{JM¾:	40000 routes >T6@|EjR140000 utils frhTE=f{f4܌	Tx Vʳ]RLCi7x	 Ps6Kx 9100644 bot.ts :5ze840000 db ׸Nwϋ)єii 100644 env.ts g"Y ԊD.o100644 index.ts vU7+{JM¾:	40000 routes kr7v*[%zQk40000 utils frhTE=f{f4܌	5Qfjx 	Px  YySB>oPYH7^x; 4S3/? >+.M40000 utils !z&4?goW\kJx frhTE=f{f4܌	yi x	 QrC	x  LqPC|q#A
XfʀH[x  r.&ӎ@IͿڭBxE 100644 auth.ts %JrQky4#I([100644 xml.ts ,̅5'fr0mSxx GD
return uri\R*parsed._]
G`${h}:host\n`)
    .join(''?Str = signedHeaders; L=LStr 5)x( ==RlB''" },
  })"lyx  vg^6~ȰRՓHw%)x <&/ު:EM
?j(x
 :D5x <+{ȢØdl^100644 package.json E%>x
XXG03FO$HޮX1h[.vv)40000 test 
%nnKM*.)l4x& 40000 superpowers }&,fek,z jx@ 40000 plans %[Ͻ_$?@3ܡK40000 specs Xk*2&⩃>Lu2C:+fnx  fx x 9100644 bot.ts :5ze840000 db xg!N<73'MUy$100644 env.ts g"Y ԊD.o100644 index.ts vU7+{JM¾:	40000 routes S3/? >+.M40000 utils <&/ު:EM
=NRgx#interface QueryResult {
	%rowCount: number;
}6// Use raw SQL for the complex prefix/startAfter query	QueryResult

7// It's under a subfolder — extract the folder prefix\M// It's a direct child object
        objects.push({
          ...row,
      V+8      s3Key: s3Key,
        } as unknown as S3FileRecord@!3
      (row) =>
        ({
          ...row,
      V+  }%'    }) as unknown as S3FileRecord,
    &QueryResult[QueryResult2ƞVx +98ܐ+)JƲCF?aHx& Nuschema.ts Z/W6{<?l74X Kn x Xa\.w"}xo =R]KP}ٱQ>+{ȢØdl^ɓ:2"W,8Q40000 src 1G[4nu6orHw20gx yg;x 	x 9100644 bot.ts :5ze840000 db 98ܐ+)JƲCF100644 env.ts _29;{:Na100644 index.ts vU7+{JM¾:	40000 routes S3/? >+.M40000 utils <&/ު:EM
?]NiIx `},
});
zdx$ 40000 workflows @V+f:x- 100644 notify-parent.yml y2T@L0I4nv˳%xSname: Notify Parent Repo

on:
  push:
    branches:
      - main

jobs:
  dispatch:
    runs-on: ubuntu-latest
    steps:
      - name: Trigger monorepo build
        uses: peter-evans/repository-dispatch@v3
        with:
          token: ${{ secrets.DISPATCH_TOKEN }}
          repository: MythEclipse/ultimate-asepharyana.tech
          event-type: submodule-updated
          client-payload: |
            {
              "service": "teleuploader",
              "ref": "${{ github.ref }}",
              "sha": "${{ github.sha }}",
              "actor": "${{ github.actor }}"
            }
\xu =w6ꂘND4M* CRQQY	`X.340000 src  |R8[ſk(40000 test Āt1}mop/̓P)3Qgzx _0x s!+file_hash VARCHAR-archive_-!--+-archive_mime_type VARCHAR-archive_size_bytes BIGINT;
&b,Ԧx 9100644 bot.ts _WT)ίD UuD40000 db 98ܐ+)JƲCF100644 env.ts _29;{:Na100644 index.ts PIYpr Imw&`iZITO40000 routes ʳEͦmp>e40000 utils IF'&H#`ywM}>x ##
ճ	c x! T@'B]"
+2H	x k100644 files.ts qUd_T+,C100644 health.ts )pvr'*C100644 swagger.ts 5O%&
)?T100644 upload.ts o`9W%M9l~x &&68/x7100644 botHealth.ts \,V{nPiCNw100644 cache.ts 9AƾqGXR100644 file.ts Ł8?_ŃZr100644 ip.ts MW]'5gf100644 logger.ts fc{_5>r(100644 metrics.ts XgT* 9At\'100644 rateLimit.ts hCt|=xZ+100644 retry.ts ӯIX!drhHDO100644 telegram.ts n~0]
-Sb)100644 telegramQueue.ts n&Imh3 |100644 uploadBatcher.ts K*r<~`100644 zip.ts sb cLftKPŦUxW :aZ4(V`ND6ˊvmjY
"׾100644 zip.ts ҈jL	B^bݢ&x#import logger from './logger';

interface BotHealth {
  index: number;
  isHealthy: boolean;
  rateLimitedUntil: number;
  failureCount: number;
  successCount: number;
  lastUsed: number;
}

class BotHealthTracker {
  private botHealth: Map<number, BotHealth> = new Map();
  private totalBots: number;

  constructor(totalBots: number) {
    this.totalBots = totalBots;
    for (let i = 0; i < totalBots; i++) {
      this.botHealth.set(i, {
        index: i,
        isHealthy: true,
        rateLimitedUntil: 0,
        failureCount: 0,
        successCount: 0,
        lastUsed: 0,
      });
    }
  }

  recordSuccess(botIndex: number): void {
    const health = this.botHealth.get(botIndex);
    if (health) {
      health.successCount++;
      health.failureCount = 0;
      health.isHealthy = true;
      health.lastUsed = Date.now();
    }
  }

  recordFailure(botIndex: number, retryAfterSeconds?: number): void {
    const health = this.botHealth.get(botIndex);
    if (health) {
      health.failureCount++;
      health.lastUsed = Date.now();

      if (retryAfterSeconds) {
        health.rateLimitedUntil = Date.now() + retryAfterSeconds * 1000;
        health.isHealthy = false;
        logger.warn('Bot rate limited', {
          botIndex,
          retryAfter: retryAfterSeconds,
        });
      } else if (health.failureCount >= 3) {
        health.isHealthy = false;
        logger.warn('Bot marked unhealthy', { botIndex, failures: health.failureCount });
      }
    }
  }

  getHealthiestBot(): number {
    const now = Date.now();
    let bestBot = 0;
    let bestScore = -Infinity;

    for (let i = 0; i < this.totalBots; i++) {
      const health = this.botHealth.get(i)!;

      // Skip rate-limited bots
      if (health.rateLimitedUntil > now) {
        continue;
      }

      // Calculate score: prefer healthy bots with fewer failures and more successes
      const score =
        (health.isHealthy ? 100 : 0) +
        health.successCount -
        health.failureCount * 10 -
        (now - health.lastUsed) / 1000;

      if (score > bestScore) {
        bestScore = score;
        bestBot = i;
      }
    }

    return bestBot;
  }

  getStats() {
    const stats = {
      healthy: 0,
      rateLimited: 0,
      unhealthy: 0,
      bots: [] as any[],
    };

    const now = Date.now();
    for (const health of this.botHealth.values()) {
      if (health.rateLimitedUntil > now) {
        stats.rateLimited++;
      } else if (health.isHealthy) {
        stats.healthy++;
      } else {
        stats.unhealthy++;
      }

      stats.bots.push({
        index: health.index,
        healthy: health.isHealthy,
        rateLimitedUntil: health.rateLimitedUntil > now ? health.rateLimitedUntil - now : 0,
        failures: health.failureCount,
        successes: health.successCount,
      });
    }

    return stats;
  }

  reset(): void {
    for (const health of this.botHealth.values()) {
      health.isHealthy = true;
      health.rateLimitedUntil = 0;
      health.failureCount = 0;
      health.successCount = 0;
    }
  }
}

export { BotHealthTracker };
Ugx 74VU:zKhy	x M8
  ]
});

// If not production, also log to console
if (w%) {
  logger.add(qWr$  F
      winston.format.simple()
    )
  }));
}

export default logger;
_6bBxq ,,
  ],jConsole({
    p!?winston.format.colorize(), winston.format.simple()),
    }),
   )#kFx @@41"}	-x i;*2y?YȠN100644 bot.test.ts G
*ԤDM;LET9`rCY.H+@=2	\'_;f
PxqEmD2b _E%x8 @6LL?p!5ıT8ӖvՑZ0Qԓl>x dV"4x 3L30);
  });
});
}l#x //f~j%%xT100644 .dockerignore mm=BP<\J5Ӫ1>100644 .env.example 
/ϐݠD,'40000 .github =q1W6#.100644 .gitignore =k;FIyR100644 CLAUDE.md ,{ϯqMC<G100644 Dockerfile "2Ɏ/Vr1/*@y100644 README.md k2`bj6A100644 biome.json x4c2mTM100644 bun.lock ?XvE8կ100644 docker-compose.yml 0eq qꇖJSWI(40000 docs sՔ%MGbZke100644 package.json E%>x
XXG100644 schema.sql Y	`X.340000 src `+M(,V40000 test dq r=8100644 tsconfig.json \/Tax8 _|(fS*ٱsiȖP9(H0kk
Gx X=w6ꂘND4M* CRQg%'pj[C	d3B3B8N,Hf40000 docs +{ȢØdl^ɓX3R!l?hXO$|)s40000 test Āt1}mop/̓+)Z-I1x rversion: '3.8'

.image: ghcr.io/mytheclipse/teleuploader:latestJ/curl -sf http://localhost:3000/health || exit 1tech1lx $	lx 9100644 bot.ts _WT)ίD UuD40000 db 98ܐ+)JƲCF100644 env.ts |7p"oC%j׆<bE100644 index.ts PIYpr Imw&`iZITO40000 routes tOc	$esR40000 utils AhDiQlįCjKzx! JF{},
});
9Τ	x k100644 files.ts NɈ%ZvH40:100644 health.ts )pvr'*C100644 swagger.ts \\\dk[100644 upload.ts ?.녹N/6>^:lvx ;;B	N	&~	x( DDa`}\
A<OjJx5 :!IQAB'tNfg%QT^q7\+wШ,j<|x !!0-']qKx  iȖP9(Hx 9100644 bot.ts _WT)ίD UuD40000 db 98ܐ+)JƲCF100644 env.ts |7p"oC%j׆<bE100644 index.ts PIYpr Imw&`iZITO40000 routes |OXI79o˹40000 utils /C%\k'S#K	x k100644 files.ts #J/#
cޒ8/z100644 health.ts )pvr'*C100644 swagger.ts \\\dk[100644 upload.ts ?.녹N/6>:mx &%=m>fx@>{ result, botToken } = awaitL<FileInfoResult>(
      async (activeBot, activeToken) => ({
        result:u13,
        botToken: activeToken,
      }),
    );

*TelegramFileInfo;
    return {

0<C+bot_token: botTokenBget file infoV{iRV%xT100644 .dockerignore mm=BP<\J5Ӫ1>100644 .env.example 
/ϐݠD,'40000 .github |(fS*100644 .gitignore =k;FIyR100644 CLAUDE.md ,{ϯqMC<G100644 Dockerfile "2Ɏ/Vr1/*@y100644 README.md k2`bj6A100644 biome.json x4c2mTM100644 bun.lock ?XvE8կ100644 docker-compose.yml 0eq qꇖJSWI(40000 docs sՔ%MGbZke100644 package.json E%>x
XXG100644 schema.sql Y	`X.340000 src iȖP9(40000 test +VXJX[)-4)m-100644 tsconfig.json \/|nx tech.$x 	tech	techZ)i~l-x #nx& 40000 superpowers UNxD騥=˓Hx@ 40000 plans xIeXNr %+UP40000 specs z3uD>OnRx6 4yGkǇ[κxw=H!C<ycv+}Bm|Fx i-:}%ty.'A}BcRx; AAtechG
techtech`)$Wz9x6 6,}+5s{jzcJ#.&1J~^0ݗэIx% ::techZtechNtechD	%ex tech`) -seHx  ӖvՑZ0Qԓ=x ==tech$KwCx //f~0y});
YQnx _|(fS*ٱsex; 4yeLɺۦHr
`40000 utils 4:4r0%wA;	x k100644 files.ts @;Elƌ100644 health.ts )pvr'*C100644 swagger.ts \\\dk[100644 upload.ts ?.녹N/6>M:Ex7 8+Y]2يM92L+]TCo2{Nzq+lhx !1xg ><4()#\1keyof Telegraf['telegram']
3K
     1	-$!^x  mۤ%tS%9قX/x7 _cG|M{Ӗʲs0eq qꇖJSWI(ͤx$ 40000 workflows &M1!/}7H\2w~[UURxS 100644 deploy.yml o1!<XN1100644 notify-parent.yml y2T@L0I4#޵x%name: Deploy TeleUploader

on:
  push:
    branches:
      - main

permissions:
  contents: read
  packages: write

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: Run Tests
        env:
          BOT_TOKEN: "mock_token"
          STORAGE_CHANNEL_ID: "123456"
          BASE_URL: "http://localhost:3000"
          DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/postgres"
          PORT: "3000"
        run: bun run test

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and Push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/mytheclipse/teleuploader:latest

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to VPS via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: 45.127.35.244
          username: root
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            mkdir -p /opt/teleuploader
            cd /opt/teleuploader

            # Log in to GHCR on VPS
            echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin

            # Write dynamic docker-compose.yml
            cat << 'EOF' > docker-compose.yml
            version: '3.8'

            services:
              app:
                image: ghcr.io/mytheclipse/teleuploader:latest
                container_name: teleuploader-app
                restart: always
                environment:
                  - BOT_TOKEN=${BOT_TOKEN}
                  - STORAGE_CHANNEL_ID=${STORAGE_CHANNEL_ID}
                  - BASE_URL=${BASE_URL}
                  - DATABASE_URL=${DATABASE_URL}
                  - PORT=3000
                  - NODE_ENV=production
                  - LOG_LEVEL=info
                networks:
                  - app-shared-net
                labels:
                  - "traefik.enable=true"
                  - "traefik.http.routers.teleuploader.rule=Host(`upload.asepharyana.tech`)"
                  - "traefik.http.routers.teleuploader.entrypoints=websecure"
                  - "traefik.http.routers.teleuploader.tls=true"
                  - "traefik.http.routers.teleuploader.tls.certresolver=letsencrypt"
                  - "traefik.http.services.teleuploader.loadbalancer.server.port=3000"

            networks:
              app-shared-net:
                name: app-shared-net
                external: true
            EOF

            # Write .env file from secrets
            cat << EOF > .env
            BOT_TOKEN=${{ secrets.BOT_TOKEN }}
            STORAGE_CHANNEL_ID=${{ secrets.STORAGE_CHANNEL_ID }}
            BASE_URL=${{ secrets.BASE_URL }}
            DATABASE_URL=${{ secrets.DATABASE_URL }}
            PORT=3000
            EOF

            # Pull latest docker image
            docker compose pull

            # Run DB migrations
            docker compose run --rm app bun run db:migrate

            # Start service
            docker compose up -d
3x tname: Build and Test)  pull_request%OXconcurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: truqOY21a0x _F ˌaNLXuqsx$ 40000 workflows 4j#Y|Ő+}+ń*@xS 100644 deploy.yml ݗgiW:'7}n100644 notify-parent.yml y2T@L0I4`z#+x$ 40000 workflows 9}qG؉l3?ƪZx& 100644 deploy.yml ݗgiW:'7}navx  letsencrypt'xX mۤ%tS%9قw3Ke^9a:)40000 test Ӆ'a.^$+)eq)Gx; 44r2  )ײB[40000 utils 4:4r0%wjt	x k100644 files.ts T\@k&;/j6R100644 health.ts )pvr'*C100644 swagger.ts RH5i1 !100644 upload.ts ?.녹N/6>u:xk ;;B	N	0Proxies file from Telegram storage as a streamedfp)binary stream.'q./E'xb \^R-朊Jɜk[GԞɅC?lD]A*$k]'100644 swagger.test.ts U3	-=1t=*@xX m75m.#Cйضw3.kNZC`Gm:40000 test ̀ށ\rl"$1+)D)Jx) őletsencrypt5}Hۦx 9100644 bot.ts _WT)ίD UuD40000 db 98ܐ+)JƲCF100644 env.ts ,ӳ5)(-[?100644 index.ts YDe.|~8`P40000 routes &~comJZ]=, 40000 utils !?ϭULtU_)NEx  (~
      ? []
      : "$ || '')
          .split(',')
      .  H58Int	!, 10) || 60000Int$!, 10) || 30?G ...config, botToken: `${config.botToken?.substring(0, 10)}...` },
});
f9wAx[ $T@'YhandleUpload+FileRedirect3FileInfo!s"+2/	x k100644 files.ts ]]=]{ºG100644 health.ts )pvr'*C100644 swagger.ts N#FP7hfuXSS100644 upload.ts !jw_p9o>= x#&"x)checkRateLimit } from '../utils/rateLimit?8extract}/yconst ip = req.headers.get('x-forwarded-for') || '127.0.0.1';

    if (!public_id || !checkRateLimit(ip)) {
      return Respon/{ error: 'Rate limit exceeded' }, { status: 429z(S,{ error: 'File not found' }, { status: 404 }s*{ error: 'Server error' }, { status: 500 }archiveBuffer = Buffer.from(await archiveResponse.arrayBuffer());
      const extractedFile = await extractZipEntry(archiveBuff/er, archiveEntryName);
      if (!extractedFile	"C_{ error: 'File not found' }, { status: 404 });
      }

      return new Response(extractedFilecS<file.fileName.replace(/"/g, ''4)extractedFile.byteLb*{ error: 'Server error' }, { status: 500 }$+{ error: 'Missing file id' }, { status: 400z(S4{ error: 'File not found' }, { status: 404 });
    }uploader_id: file.uploaderIdwٓ4{ error: 'Server error' }, { status: 500 });
  }
};
JNEx9 D<j> 
/p	Zx e[u :?ZaCq$ bZF_Gt-<wG6lӓcb։U~I|4iƸHvoL100644 uploadBatcher.ts gݒ۱43G7eN{100644 zip.ts 0 9ռ:fKżHs1w.=nB0}xM
I %// Simple sliding window rate limiterhWINDOW_SIZE_MS = 60000; // 1 minute window
const MAX_REQUESTS_PER_WINDOW = 100; // 100 requests per minute per IP

export constTt0// No entry or window expired - create new entry.AWINDOW_SIZE_MSj"G// Check if limit exceeded
  if (entry.count >= MAX_REQUESTS_PER_WINDOWb// Increment counter2.G<WINDOW_SIZE_MS,
  maxRequests: MAX_REQUESTS_PER_WINDOW,
});
1΍G`x' 	>0Number.POSITIVE_INFINITYQ%d{x C7HN@@*<let crc = 0xffffffff;

      const chunks: Buffer[] = [];
  9k#      chunks.push(chunkXk&const crc32 = (crc ^ 0xffffffff) >>> 0m"for (const chunk of chunks) {
    chunk);
      }A
KOx{ $U]4e{"C?~cM0bR!RB100644 swagger.test.ts  	EosoL?r8wtsMԝ{N
PVn8';'91"Bx*64Kuploader_id: numberGrateLimit
const mockCheckRateLimit = mock(() => true);
mock.module('../src/utils/rateLimit', () => ({
  checkRateLimit: mockCheckRateLimit,
})#'CheckRateLimitUV	>29 if rate limit is exceededCheckRateLimifalse);oV229YRate limit exceeded'")B.redirect to telegram file url if file is found"302);O/U(
        'https://api.telegram.org/file/bot123456:ABC-DEF/photos/file_0.jpg',
      Oif file is found-[
uploader_i/%jz	*});
});
7xM
"// @ts-nocheck
import { afterEach,!, spyOn) logger from '../src/utils/logger:1#;// Spy on logger.warn
const warnSpy = spyOn(logger, 'warn')=warnSpy.mockClear/ways allow requests as rate limiter is disabled<%%	warnSpy).not.toHaveBeenCalledno-op on cleanup', () => {)@});
q]x import {"4P>
$x Y<post?: { requestBody: { content: Record<string, unknown> } }L['/api/upload']P*"
      'multipart/form-data',
    ['/api/upload']P?});
Na5bfxrF@000b08000100telegram_file_id: string;
  tconst mockGetFil`',file_size: 1000,
    mime_type: 'image/jpeg'GetFile#	XGetFile	{
~telegram_file_id).toBe('tg-file.).toBe('tg-unique-id-abcQ/
8	Ntelegram_filetg-idQ	tg-uniqueQ/K*telegram_fileR/f$#/cw7yxX m%}5v=U[w3.kNZC`Gm:40000 test ̀ށ\rl"$1+)5)kpxu version: '3.8'

.image: ghcr.io/mytheclipse/teleuploader:latestrȴtech`)-letsencrypt`50	H
*exv m%}5v=U[YQB#4 ρ540000 src ҟg<a;W40000 test K{l?˱ L^t!s+)A4g(x p&ړ<mix 9100644 bot.ts _WT)ίD UuD40000 db ifwW.]bCZ%100644 env.ts ,ӳ5)(-[?100644 index.ts YDe.|~8`P40000 routes  'òuDPn]e(40000 utils 97~~RB;A$0haLBSx; 40Sb/)WLjCU40000 utils +,"E(4yFtFx &[xĿB?|sn\x Xa3h٤	x k100644 files.ts y=,bw$7c;u100644 health.ts )pvr'*C100644 swagger.ts N#FP7hfuXSS100644 upload.ts q@Q iB{q<Oix^"+[T#P// Check cache first
    const cacheKey = `file_info_${file.telegramFileId}`;
  41C  if (!fileInfo) {
      // Cache miss - fetch from Telegram API
  v1+file.telegramFileId);
      fileInfo = {
  <Qn7  };
      // Store in cache
  /%  } else {
  E  }

    const redirectUrl =BInfo.file_path}`f(jg"x Ygx QHx8 -'NW|ݟ0ad/
[ױ_Pvlmw{\R;|\H[jx<y# ';x 9100644 bot.ts _WT)ίD UuD40000 db ifwW.]bCZ%100644 env.ts ,ӳ5)(-[?100644 index.ts YDe.|~8`P40000 routes W _ec>ex }ӂk40000 utils })Уisfz-*vbMK0	x k100644 files.ts m8yd3pvэ100644 health.ts )pvr'*C100644 swagger.ts N#FP7hfuXSS100644 upload.ts 19|(Q(8s;Hhx <l20+
I>+nxKG0
unlinkSync,s, getBothFtype TelegramFileLookup = {
  mime_type?: string;
  file_size?: numberyfileBuffer: Buffer*C&cc$
tempPath);A);
    const bot = getBot();
    const fileInfo = (await bot.telegram.getFile(result.telegramFileId)) as TelegramFileLookup;

 fileInfo.mime_type ||:<fileInfo.file_size || fileBuffer.byteLength,
      fileType: S'computeHash(fileBuffer)UWsetTimeout(() => {
      try {
        unlinkSync(tempPath);
      } catch {}
    }, 50.a\Fawait file.arrayBuffer();
    const fileBuffer = Buffer.from(fileBytes%ufferfilqv
fileBuffer0!!
fileBuffer "C"3\^!	fileBytes "C"Z "Dx00: } from 'node:fs';
import { unlink } from 'node:fs/promise4h!
A=.async () => {
      try {
        await unlink	(err) {
        logger.warn('Failed to cleanup temp file', { tempPath, error: getErrorMessage(err) });
      }
    }, 500);
  }	PV#xd 8'-L xRJqRLZ3h:RAw?@@V_,Y/a'LZ=X-*؂u} y"GC**x3 T() => {
a->}
}, 5 * 60 * 1000V_xA () => {
5E&m+<.2});
}, 5 * 60 * 1000V!Oj|!xkD>-y/	C"
      activeBot.telegram.getFile(telegramFileId),
    );

    const fileData = result as unknown as TelegramFileInfo;
    retu Data.file_size || 0,
      mime_type: fileData.mime_type || 'application/octet-stream',
      file_path: fileData.file_path || ''q!哣B
export const getCurrentBotIndex = (): number => currentBotIndex;
[+|>x -'NW|ݟ0ad/
[בB9D%7aqR-_100644 docker-compose.yml %}5v=U[3]DW_pS'wэI100644 schema.sql B#4 ρ540000 src F<xKXh,=40000 test K{l?˱ L^t!s100644 t3!fb%tx( ̯!?@JkzKm 
,l-x$onx 	OՓ=1fJx; 4p@j\.EF)*40000 utils PԻ-^O?={;?λۤ	x k100644 files.ts >!Y7	;ԡ e/100644 health.ts )pvr'*C100644 swagger.ts N#FP7hfuXSS100644 upload.ts I&Xw`uŎifg$;)x <l6jconst f;k
Ox 0.h!
A=`x <100644 file.ts u :?ZaCq$ bZF100644 logger.ts fc{_5>r(100644 rateLimit.ts tZl.A<}*100644 telegram.ts [ڂcc100644 telegramQueue.ts .QE5LfYڇF]MPx ,Y/a'LZ=X,x? 
=_\]F,// No-op karena rate limit dinonaktifkan
};
{Cx ^Stask();
};
Dx -'NW|ݟ0ad/
[בB9D%7aqR-_100644 docker-compose.yml %}5v=U[3]DW_pS'wэI100644 schema.sql Zwjb{}(TT40000 src EoKƸfռ,V40000 test ErG{ښ#100644 t3!ce'yx? 3ކKF9TG?K40000 test xvA],EZ1i8gAH/+)8kdx $hmw	x k100644 files.ts H%7jNND㦪j100644 health.ts )pvr'*C100644 swagger.ts N#FP7hfuXSS100644 upload.ts $IEt2_!<rxZ <l+w6const { getBot } = await import('../utils/telegram');
fconst f;k qx   F$bl[5{x 9100644 bot.ts euueҏ@x׻40000 db |/]ּCOC> 100644 env.ts #/|Z+<%$Y c100644 index.ts YDe.|~8`P40000 routes 6hNw&_vfZҸ40000 utils L 0wq(¨:^MLSx3 M,ӳ5)(-[?aQҺ
+x7ӥIV.Bx ؇F7:qGݑ TYx J)!&		@xo 100644 index.ts vQfzZx.v100644 migrate.ts R7Aq2JfJP100644 schema.ts &[xĿB?|7+
x VIz 7const schemaSql = await Bun.file('schema.sql').text();
~7try {
!-} catch (error: any) {
.5error.message });
!} finally {
  await sql.end();
}
'7	x k100644 files.ts 5z\AUhP)=*7100644 health.ts 
F97MZC100644 swagger.ts 읩.{S  Kc100644 upload.ts ah_
:=!x =%px툭sBJ
綥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f$import { createReadStream, unlinkSync } from 'node:fs';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { db, files as fileSchema } from '../db';
import { config } from '../env';
import {
  checkFileSize,
  computeHash,
  ensureExtension,
  extractMimeType,
  getFileType,
} from '../utils/file';
import logger from '../utils/logger';
import { forwardToStorage, getBot } from '../utils/telegram';

export const handleUpload = async (req: Request): Promise<Response> => {
  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: any) {
    logger.error('Upload error', { error: error.message });
    return Response.json({ error: error.message }, { status: 500 });
  }
};

const handleMultipartUpload = async (req: Request): Promise<Response> => {
  let tempPath = '';
  try {
    const formData = await req.formData();
    const file = formData.get('file');
    const fileName =
      (formData.get('fileName') as string) || (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 hash = computeHash(fileBuffer);

    // Check for duplicate in DB
    const existing = await db
      .select()
      .from(fileSchema)
      .where(eq(fileSchema.fileHash, hash))
      .limit(1);

    if (existing.length > 0) {
      const existingFile = existing[0];
      const responsePayload = {
        public_id: existingFile.publicId,
        telegram_file_id: existingFile.telegramFileId,
        telegram_file_unique_id: existingFile.telegramFileUniqueId,
        storage_chat_id: existingFile.storageChatId,
        storage_message_id: existingFile.storageMessageId,
        file_name: existingFile.fileName,
        mime_type: existingFile.mimeType,
        size_bytes: existingFile.sizeBytes,
        file_type: existingFile.fileType,
        uploader_id: existingFile.uploaderId,
        created_at:
          existingFile.createdAt instanceof Date
            ? existingFile.createdAt.toISOString()
            : new Date(existingFile.createdAt).toISOString(),
        download_url: `${config.baseUrl}/f/${existingFile.publicId}`,
      };
      return Response.json(responsePayload, { status: 200 });
    }

    const rawMimeType = file.type || extractMimeType({}, req) || 'application/octet-stream';
    const { fileName: finalFileName, mimeType } = ensureExtension(
      fileName,
      fileBuffer,
      rawMimeType,
    );
    const fileType = getFileType(mimeType, finalFileName);

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

    // Write file to disk temporarily
    tempPath = `/tmp/teleuploader-${nanoid()}`;
    await Bun.write(tempPath, fileBuffer);

    const fileStream = createReadStream(tempPath);
    const result = await forwardToStorage(fileStream, finalFileName, fileType);
    const bot = getBot();
    const fileInfo = (await bot.telegram.getFile(result.telegramFileId)) as any;

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

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

    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: any) {
    logger.error('Multipart upload error', { error: error.message });
    return Response.json({ error: error.message }, { status: 500 });
  } finally {
    if (tempPath) {
      const p = tempPath;
      setTimeout(() => {
        try {
          unlinkSync(p);
        } catch {}
      }, 50);
    }
  }
};

const handleJSONUpload = async (req: Request): Promise<Response> => {
  let tempPath = '';
  try {
    const { file, fileName = 'file' } = (await req.json()) as any;

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

    let base64Data = file;
    let rawMimeType = 'application/octet-stream';
    if (file.startsWith('data:')) {
      const match = file.match(/^data:([^;]+);base64,(.+)$/);
      if (match) {
        rawMimeType = match[1];
        base64Data = match[2];
      }
    }

    const fileBytes = Buffer.from(base64Data, 'base64');
    const hash = computeHash(fileBytes);

    // Check for duplicate in DB
    const existing = await db
      .select()
      .from(fileSchema)
      .where(eq(fileSchema.fileHash, hash))
      .limit(1);

    if (existing.length > 0) {
      const existingFile = existing[0];
      const responsePayload = {
        public_id: existingFile.publicId,
        telegram_file_id: existingFile.telegramFileId,
        telegram_file_unique_id: existingFile.telegramFileUniqueId,
        storage_chat_id: existingFile.storageChatId,
        storage_message_id: existingFile.storageMessageId,
        file_name: existingFile.fileName,
        mime_type: existingFile.mimeType,
        size_bytes: existingFile.sizeBytes,
        file_type: existingFile.fileType,
        uploader_id: existingFile.uploaderId,
        created_at:
          existingFile.createdAt instanceof Date
            ? existingFile.createdAt.toISOString()
            : new Date(existingFile.createdAt).toISOString(),
        download_url: `${config.baseUrl}/f/${existingFile.publicId}`,
      };
      return Response.json(responsePayload, { status: 200 });
    }

    const { fileName: finalFileName, mimeType } = ensureExtension(fileName, fileBytes, rawMimeType);
    const fileType =
      getFileType(mimeType, finalFileName) === 'application'
        ? 'document'
        : getFileType(mimeType, finalFileName);

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

    // Write file to disk temporarily
    tempPath = `/tmp/teleuploader-${nanoid()}`;
    await Bun.write(tempPath, fileBytes);

    const fileStream = createReadStream(tempPath);
    const result = await forwardToStorage(fileStream, finalFileName, fileType);
    const bot = getBot();
    const fileInfo = (await bot.telegram.getFile(result.telegramFileId)) as any;

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

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

    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: any) {
    logger.error('JSON upload error', { error: error.message });
    return Response.json({ error: error.message }, { status: 500 });
  } finally {
    if (tempPath) {
      const p = tempPath;
      setTimeout(() => {
        try {
          unlinkSync(p);
        } catch {}
      }, 50);
    }
  }
};
5x <100644 file.ts 12NFR)9 100644 logger.ts fc{_5>r(100644 rateLimit.ts tZl.A<}*100644 telegram.ts S:O{¼cc	100644 telegramQueue.ts q)=o@9);}p%ȾIPx .QE5LfYڇpe^x"7
Name = (msg: any, request: any): string => {
  if (request?.headers?.['x-file-name']) {
    return request.headers['x-file-name'];
  }Sany, request: any): string => {
  if (request?.headers?.['x-mime-type']) {
    return request.headers['x-mime-type'];
  }
  retoS_nx
6D=!	 = async /anyO2anyrany.message ||<`B// 429 rate limit hit! Rotate bot index instantly
      const prev3A
const nextoC?5*!anyRa$: any = await enqueueUpload(async ()WBphoto') {
          return?Photo}$={
            caption: fileName,
          });
        } else{
          return?Audio}$j{
            caption: fileName,
          });
        } else if (fileType === 'video') {
          return?Video}$j{
            caption: fileName,
          });
        } else if (fileType === 'voice') {
          return?Voice}${
            caption: fileName,
          });
        } else if (fileType === 'animation') {
          return activeBot.telegrP	Animation}$l{
            caption: fileName,
          });
        } else if (fileType === 'sticker') {
          return?Sticker}"$);
        } else {
          return?Document}${
           T,
          });
        }
      });

      // Advance round-robin index for next job0>m)let uploadedFile: any;
  OIuploadedFile = result.document;
    else if (result.photo) uploadedFile =  else if (result.video) uploadedFile = result.video;
    else if (result.audio) uploadedFile = result.audio;
    else if (resu/lt.voice) uploadedFile = result.voice;
    else1euploadedFile = result.animation;
    else if (result.sticker) uploadedFile = result.sticker;
    elseLuploadedFile = result.video_note;
    else uploadedFile = result[fileType];
Vany{; fileName, error: error.messageS: any = await enqueueUpload(async () => {
      const mediaGroup: any = items.map((item) => {
        let type: 'photo' | 'video' | 'audio' | 'document' = 'document';
        if (item.fileType === 'photo') type = 'photo';
        else if (item.fileType === 'video') type = 'video';
        else if (item.fileType === 'audio') type = 'audio';

        return {
          type,
          media: item.fileId,
            };
      }Rreturn?"lm8)msg = messages[i];
      const fileType =!;
      let uploadedFile: any;

      if (msg.document) uploadedFile = msg.document;
      else if (msg.photo) uploadedFile = msg    else if (msg.video) uploadedFile = msg.video;
      else if (msg.audio) uploadedFile = msg.audio;
      else if (msg.voice) uploadedFile = msg.voice;
      else if (msg.animation) uploadedFile = msg.animation;
      else if (msg.sticker) uploadedFile = msg.sticker;
      else if (msg.video_note) uploadedFile = msg.video_note;
      else uploadedFile = msg[fileType];

      tany{( error: error.message%: any = await result.json(): any = await fileResult.json()z any{ ! error: error.message%2"('I>x100644 bootstrap.test.ts ĕJBt*ۧfW100644 bot.test.ts o{ePc J$100644 db.test.ts g l<)6Ϫ	c+100644 env.test.ts WZף UK5~۫100644 file.test.ts ͹FT|:ā100644 files.test.ts fxqybq100644 health.test.ts !X"
Tg100644 rateLimit.test.ts e[r?Z[5
(g100644 swagger.test.ts TF0l~*uwv/`v100644 telegram.test.ts Cm.Eg[v*ď100644 telegramQueue.test.ts {j`\z 100644 upload.test.ts hT.lrnTC@e̒x ('XVFz	Ko撍xſ___X7/?9#59kdnFyFɮ7f|Ƹ9Ŝi/;[l 4xO=:// @ts-nocheck
[/tech'mock.module('telegraf', () => {
  return {
    Telegraf: class {
      constructor(token) {
        this.token = token;
       ( this.launch = mockLaunch;
        this.I      this.on = mockOn;
        this.use = mockUse;
      }
    },
  };
}Rdconst mockLimit0[]));
const mockWhere = mock(() => ({
  limit: mockLimit,
}));
const mockFrom = mock(() => ({
  where: mockWhere,
}));
const mo/ckSelect = mock(() => ({
  from: mockFrom,
}));~K^  select: mockSelect,
  },
  files: {
    telegramFileUniqueId: 'telegram_file_unique_id',
  }3QLimit!	!Limit.mockResolvedValue([]);
    	:[1]7pF$W7p87p8W<4// Mock DB to return an existing match
    mockLimitm[
      {
  ,(.  },
    ]);
o9Zi7p87pin$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('}<'\	#'<'\*{+Px6-// @ts-nocheck
NAmockSelect = mock(() => ({
  from: mock(() => ({
    where: mock(mock(])),
    })),
  })),
}));

mock.module('../src/db/index', () => ({
  db: {
    select: mockSelect,
  },
  files: {
    publicIdC: {
      equals: (val) => ({ type: 'equals', value: val }),
    },rateLimit
const mockCheckRateLimit = mock(() => true);
mock.module('../src/utils/rateLimit', () => ({
  checkRateLimit: mockCheckRateLimit,
})#Fany, handleFileInfo: any_CheckRateLimitUV	>29 if rate limit is exceededCheckRateLimi%false);
      const req = new Request	);
    'test-id' };
229.json(N"Rate limit exceeded'"new Request/Q'missing-id' }3@.json(N",,direct to telegram file url if file is found"Mnew Request	);
    'test-id' }3302);O/U(
        'https://api.telegram.org/file/bot123456:ABC-DEF/photos/file_0.jpg',
      new Request	);
    'test-id' }3".json(Nynew Request);
    'missing-id' }$:K.json(N"@if file is found-new Request*);
    'test-id' }$:)".json(
uploader_i/new Request*);
    'test-id' }$:)".json(NC	'});
});
wx  

// @ts-nocheck
!anyS	jYx
 #+xm 
any)B['/api/upload']P*"
      'multipart/form-data',
    ['/api/upload']P?});
*!1x? 76// @ts-nocheck
^"any, getFileInfo: any, getBot: any[
x S@<// @ts-nocheck
c
any[] = []7{any		res.json(	res.json(:!-kB	res.json(%res.json();=+	res.json(:	res.json(:.*d:%xT100644 .dockerignore mm=BP<\J5Ӫ1>100644 .env.example 8!`aHu?40000 .github =q1W6#.100644 .gitignore -'NW|ݟ0ad/
[100644 CLAUDE.md vLٝi~~r(	100644 Dockerfile "2Ɏ/Vr1/*@y100644 README.md k2`bj6A100644 biome.json x4c2mTM100644 bun.lock 9D%7aqR-_100644 docker-compose.yml %}5v=U[40000 docs 8<O dU&42?100644 package.json ]DW_pS'wэI100644 schema.sql Zwjb{}(TT40000 src &qt85j@JͶj40000 test 7k(8qG%Y100644 tsconfig.json \/@Zax x=
/ϐݠD,'ёQYjZ
ǈ+@!/sՔ%MGbZkeX3g̈1O
c@>&[I40000 test E__&.zzaJ
c)+)9bxW FM1U^;zPñ:30m]G:{Q/40000 test 9>|-OX-硐bN_+)$fx? 3EoKƸfռ,V40000 test ErG{ښ#+)l	x !~Lx(HE8Hconst isDocument =
      finalFileName.endsWith('.pdf') ||
      finalFileName.endsWith('.txt') ||
      !['photo', 'video', 'a.udio', 'voice', 'animation'].includes(fileType1!Buffer, finalFileName, isDocumenth.<glconst isDocument =
      finalFileName.endsWith('.pdf') ||
      finalFileName.endsWith('.txt') ||
      !['photo', 'video', 'a.udio', 'voice', 'animation'].includes(fileType1 Bytes, finalFileName, isDocument
};
qgx4 EB(;fileType!%fileType!p+x? <<Wn/'mocked-nanoid-id'vBe?Be?&3txW Dqy9i6m:30m]G:{Q/40000 test 9>|-OX-硐bN_+)	H&x M<F#xUkontrol oleh antrian in-memory (`TelegramQueue` di `src/utils/telegramQueue.ts`) dengan batas konkurensi = 2 untuk mencegah rate limit berlebih.
- Mendukung pengiriman batch upload (Media Group) di `src/bot.ts` dengan membungkus berkas yang memiliki `media_group_id` yang sama menggunakan debounce timer 600ms, lalu mengunggahnya secara sekaligus via `forwardMediaGroupToStorage` di `src/utils/telegram.ts`M$Ix? "100644 bot.ts  (}{ؿ&*0"p:WU'SҟzEF׺DxJtype QueueTask<T> = {
  task: () => Promise<T>;
  resolve: (value: T | PromiseLike<T>) => void;
  reject: (reason?: any) => void;
};

class TelegramQueue {
  private activeCount = 0;
  private queue: QueueTask<any>[] = [];
  private concurrency: number;

  constructor(concurrency = 2) {
    this.concurrency = concurrency;
  }

  public enqueue<T>(task: () => Promise<T>): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      this.queue.push({ task, resolve, reject });
      this.processNext();
    });
  }

  private async processNext(): Promise<void> {
    if (this.activeCount >= this.concurrency || this.queue.length === 0) {
      return;
    }

    const item = this.queue.shift()!;
    this.activeCount++;

    try {
      const result = await item.task();
      item.resolve(result);
    } catch (error) {
      item.reject(error);
    } finally {
      this.activeCount--;
      this.processNext();
    }
  }
}

const telegramQueue = new TelegramQueue(2);

export const enqueueUpload = <T>(task: () => Promise<T>): Promise<T> => {
  return telegramQueue.enqueue(task);
};
+]
1xN iߟRm|E6HzS2)ہzcB~>nV% c1Ȭm V7!b	=x b70// @ts-nocheck
%"any, getFileInfo: any, getBot: anyDphoto data')l%jpphoto data')l%jpphoto data')l%jpT4`?xV <6(35g&$pDtxttype).toBe('documentfYQtxt	U62x8 0|Q:Vi:(y1qrX-1HWlx LSD?x W֡5@'P@2x Z9qX,E>,h3x =,	B?-ztxW 0|Q:Vi:3H6BP;P,>c40000 test PEYޯOR`v|$t+)&x 9100644 bot.ts euueҏ@x׻40000 db |/]ּCOC> 100644 env.ts iM6'p? +}W
100644 index.ts YDe.|~8`P40000 routes 6hNw&_vfZҸ40000 utils )"Ņܧo8~PG@BM0x+ %? []
8.split(',')
&4[|rx Z:ocTu>ee'4,Osx =+1<QH5	Bx  5H΃C)'IDtgxo <&xgӮ1msՔ%MGbZkeX3OL)WAlkNP^$B-M40000 test *cF^T/<+)$/eqx2i,䌉  9@x lhelper `withRetry50Ox eFqv&5ȧ"U	-x{-1{B⭛12MclnW2 nN~ECM3 <C*36mﮠ_gRԃ&ekLPմ O-yx OcCP.>7/wx=#e)
const bot = new Telegraf(config.botToken*POconst withRetry = async <T>(fn: () => Promise<T>, retries = 5): Promise<T> => {Ofn() i     if (match && retries > 0) {
F4Clogger.warn(`Telegram 429 Too Many Requests detected. Retrying afte$});
Q"return withRetry(fn, retries - 1);O=let result: any;

    result = await withRetry(() => b
	C
}));
!
0audio') {
      result = await withRetry(() => b	C
}));
!
- result = await withRetry(() => b`
C
}));
!

 result = await withRetry(() => bC
}));
!
 result = await withRetry(() => bG
}));
!
G result = await withRetry(() => bk:6);
    } else {
      result = await withRetry(() => bF#
}));
    }	<;
?)kZx #bxo =8!`aHu?QYvLٝi~~r(	:3&qt85j@JͶj40000 test UA r,3mLva+)61	x )L > 	x h100644 file.ts 12NFR)9 100644 logger.ts fc{_5>r(100644 rateLimit.ts ܴĒgh"100644 telegram.ts :ib2Ue**͔'d-t!8Fx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XxT #޳<5__ c_>?_#ct!;$	l	B
Xx \@:#0aqrO!;100644 db.test.ts BOUAAe^W%Cz;"7}ө1n}$đldPE,2I8Rn3|JfJICHhR
vRx)l*v#sYz%C~(T?C2x8 fg l<)6Ϫ	c+zC,$t*4ѓ*)GxK @ bva6ˎ	(=
Ta͹FT|:ānV% c1Ȭm ?(#Yx8 @o{ePc J$T8fѯ|OᏓ*b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'+_(x 0(92$'6x& 40000 superpowers VTVH-x@ 40000 plans I4bE~l
*<40000 specs z3uD>Onx8 @å"8%tP.GT8fѯ|OᏓ*4)xX :92'8605908810:AAFpUzlIBktfd_7wpEj7zMJob2CFxvG-ZGY';
= '-1003996572954Mvx? 3?*X~"$'}G40000 test ,0hhN)WT~L`K+)T%x 9100644 bot.ts Y{]0R?9^440000 db |/]ּCOC> 100644 env.ts #/|Z+<%$Y c100644 index.ts YDe.|~8`P40000 routes ۱4Q)REs_7@U40000 utils 5.΍IC HgZ+dLiSx v|¶p)|R~윑 :zx J)!&	cqx JZ/Rk#G/_ CV<	Kx 3?b9o)ҕp!xDe)
const bot = new Telegraf(config.botToken*Puғ*!0any,
  fileName: string,
  forceDocument = falsez39caption = forceDocument ? `📁 ${fileName}` : fileName;
GIconst result: any = forceDocument
      ? await bot.telegram.sendDocument}$1{ caption })
      : await bot.telegram.sendPhoto}${ caption });
!forceDocument ? result.document :Vany{; fileName, error: error.message%: any = await result.json(): any = await fileResult.json()z any{ ! error: error.message%2"";
!YXx 99$0
e._xG 0(k#d%false+true);

false^ۓ2$'\Vx ((kd*Dk>x =8!`aHu?QY:vLٝi~~r(	100644 Dockerfile ]4erroPhGDrTI[Z`W2%-Ab2]1100644 schema.sql _oQEûҭ/{n"[40000 src }+LɢpZ40000 test GxEŠ100644 t3!GWm	xX 8<O dU&42?X3?*X~"$'}G40000 test ,0hhN)WT~L`K+)"jEx
 Ckpx? <L psql $DATABASE_URL -f schema.sqlOkՓYyOx" Bbun src/db/migrate.tsb1;x*91U!u4ɾ_0Nff62ٞjr;> E:x  rTI[Z`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x ~100644 2026-05-17-telegram-uploader-backend-design.md ,}+5s{jzc100644 2026-05-18-deploy-design.md .&1J~^0ݗэ/lx ,_2zߥ;ؿaB06xs]LB_x<6^rTI[Z`W2%-Ab2]1100644 schema.sql _oQEûҭ/{n"[40000 src }+LɢpZ40000 test 3,a"JnE=dV100644 t3!Xbxo _=q1W6#.ڑs]]4erroPhGD3HYth5i<ϡk40000 test z"	Uʯ_&pf+)1N2P~x? 31XFNb"`40000 test <M	3E:Î+)yEx ~+ӇΚHEH溂$xt EEK checkFileSize, computeHash, ensureExtension, extractMimeType, getFileType ;251hгo
251hX	!*@(x NŎP!CBJ97
tQxV ))HK checkFileSize, computeHash, extractFileName, extractMimeType, getFileType f x 9100644 bot.ts ^@;^ZJP_40000 db |/]ּCOC> 100644 env.ts #/|Z+<%$Y c100644 index.ts YDe.|~8`P40000 routes ۱4Q)REs_7@U40000 utils 5.΍IC HgZ+dOKE	x tJ"&fFT 'document' | 'photo' | 'video' | 'audio' | 'voice' | 'animation' = ctx
            9
'animation>=!+k1,qx NŎP!CBJ97
t.x nV% c1Ȭm 
}nx,=!// @ts-nocheck
[P2'8605908810:AAFpUzlIBktfd_7wpEj7zMJob2CFxvG-ZGY';
L= '-1003996572954}tech'mock.module('telegraf', () => {
  return {
    Telegraf: class {
      constructor(token) {
        this.token = token;
       ( this.launch = mockLaunch;
        this.I      this.on = mockOn;
        this.use = mockUse;
      }
    },
  };
}Rd`;J	]A:[1]7pF$W7p8ȓ<7p8W/w`"x? 3m+`c=aoWQ840000 test %.tr:3vuhp
Kv+)E#x 9100644 bot.ts ^@;^ZJP_40000 db |/]ּCOC> 100644 env.ts #/|Z+<%$Y c100644 index.ts YDe.|~8`P40000 routes Ȍ7%At_D40000 utils 5.΍IC HgZ+dWK'Qx ʅzww?cdx' E2"<V
`R
8I`!"{@'SXxWF// @ts-nocheck
$5
// Mock db%/'mocked-nanoid-id'(F	(() =>
  o(		  }),
  )T!any	>
	res.json(o%3	res.json(%$telegram_file_id).toBe('tg-file.).toBe('tg-unique-id-abcQ/testM/+document{	res.json(oóMres.json();@@txt#<p@mHx? 3槮Lw<rzGn͸>40000 test  >AptA,fRђD+)x 9100644 bot.ts ^@;^ZJP_40000 db |/]ּCOC> 100644 env.ts #/|Z+<%$Y c100644 index.ts YDe.|~8`P40000 routes Ȍ7%At_D40000 utils SdLWXN}N,	x h100644 file.ts ~A0rvƧ-~L 100644 logger.ts fc{_5>r(100644 rateLimit.ts ܴĒgh"100644 telegram.ts 3?b9o)ҿ};px kƳh||`qR6xi )!H> checkFileSize, extractFileName, extractMimeType, getFileType Xn	zO
});
P$xx D]4erroPhGDrTI[Z`W2%-Ab2]1100644 schema.sql _oQEûҭ/{n"[40000 src  (=40000 test GxEŠ100644 t3!Scx +zrN)
	U-?,x oo[҄ @H\p,F)
\n[x Xa1:kx  C,$t*4ѓ*k x 	O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Yx &"O#^7z%x\H.Z{- checkFileSize, extractMimeType, getFileType +Um
Y#const isDocument =
      fileName.endsWith('.pdf') ||
      fileName.endsWith('.txt') ||
      !['photo', 'video', 'audio', 'vo$ice', 'animation'].includes(fileType1Buffer, fileName, isDocumenttIhG' !|.<g1Buffer.from(file, 'base64');
    const mimeType =)Q&Kconst isDocument =
      fileName.endsWith('.pdf') ||
      fileName.endsWith('.txt') ||
      !['photo', 'video', 'audio', 'vo$ice', 'animation'].includes(fileType1Bytes, fileName, isDocumenttIȳ' 
};
Ƅ	x h100644 file.ts .V/ Ou`,C100644 logger.ts fc{_5>r(100644 rateLimit.ts ܴĒgh"100644 telegram.ts 3?b9o)p:x17Ɠ]||Γany, request: any): string => {
  if (request?.headers?.['x-file-name']) {
    return request.headers['x-file-name'];
  }
  retSany, request: any): string => {
  if (request?.headers?.['x-mime-type']) {
    return request.headers['x-mime-type'];
  }
  retg xW _!Z]6tdh8@B}s3}+LɢpZ40000 test GxEŠۓ+)g)x$ 40000 workflows #Y4eHu`^(px& 100644 deploy.yml ;Tqh_y0?KZ  jsx
 Ct*x8 _!Z]6tdh8@B}suvȠI^
+)-x  WoZ|=3IK*x7 _!Z]6tdh8@B}s]]4erroPhGDpdC)x ?7(// @ts-nocheck
%B$"any, getFileInfo: any, getBot: anyDphoto data')l%jpAfalse+trueJ-1003996572954{photo data')l%jp:false0x:k=x$ 40000 workflows vppȐDYN$x& 100644 deploy.yml 56Y"ǉYq|x Ct1.1SH?x !1.1-!H1.1-iCks!xP _2zߥ;ؿaB06xs]LB_x<6^3tvNu[[.+)&
[x \@:#0aqrO!;100644 db.test.ts N',1QneTz;"7}ө1n}$đldPE,2I8Rn3|JfJICHhR
vRx)l*v#sYz%C~(.?<x) o(schemaFiles$	O7,i)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Igx 6Qs]LB_x<6^o40000 docs rTI[Z`W2%-Ab2]1100644 schema.sql _oQEûҭ/{n"[40000 src }+LɢpZ40000 test tvNu[[.100644 t3!~VYx  <G#MnJN=\\^ZePƠ/x= )zh.100644 docker-compose.yml %}5v=U[1f+kzx )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Nx? Y3g~F*9d:i40000 test Tc%8X
g蓌)m>Qx @?64N wCf/x Đ&-aJk^%|J;
x Pinput: any = forceDocument
      ? { document: fileChunk, caption }
      : { photo: [fileChunk], caption };

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

J=҈--9QG/o%
4xI GF"	tele.asepharyana.tech/f/xYz123!
%},
  });
};
/xX 	)vwh|tq=V)03T@+iɨ΁xN40000 test 'Qi?u=N6)(Olx 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!x 
,=7hRx nPv	k:x P{OI#x `}]"٣|@x ̯ %
T`7
Xx   "7_Xrgd)L=Ix !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+Lx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/iEx	 d5x[ 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	mx 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>
%x Smsg.document?.fileName ||.=     msg.voice?.fileName || msg.animation?.fileName || 'file'].% msg.audio?.mimeType ||
     o;
};
~7+x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%] 5x 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<*
Kx \"it, expect } from "bun:test">"../src/db/index"k'>"../src/db/schema";

describe("Database Layer", () => {
  it("""5"oi"%"T35yx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]
x(-,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Zx+
	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""");
  });
});
ZY6YWx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(""@N9x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Ox? 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+xW 1$kjj!jjjj/j/otstrap.test.js#
  }
}
Ox4 рTa\;=WzOj;l83_s2Ex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Ux 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#2RX<M%Klw