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

PACK     Hxtree 16e66173099d1f84a9b4285e8d3dc8bebe2493b0
parent d1c8cc9d23339793d48292bc3c764d79f67e4a6b
author asepharyana <superaseph@gmail.com> 1783379057 +0700
committer asepharyana <superaseph@gmail.com> 1783379057 +0700

fix: use safe LIKE prefix matching instead of non-character range
YHx7tree 47cd6b8eb27629dbaa3c909f8725a9046264a374
parent 34962eb08a476d0c5b5a68fe5045afb1977e49f6
author asepharyana <superaseph@gmail.com> 1783378936 +0700
committer asepharyana <superaseph@gmail.com> 1783378936 +0700

fix: ensure S3_DEFAULT_REGION and S3_VHOST_DOMAINS env vars are properly set in docker-compose
"ax"tree 4c9323551dc76df9c07a24b80b7ef190413f2685
parent 8e6ccf211081ef674b692e6d792c2a7e200f04cc
author asepharyana <superaseph@gmail.com> 1783378064 +0700
committer asepharyana <superaseph@gmail.com> 1783378064 +0700

chore: update S3 vhost domains to include asepharana.web.id root/wildcard
I\dCx=tree 4008191d82a36efb4eab6b93c73fb72fc1711986
parent 6aee3723fb0f46b1ed4465fc92a95abc0d2a9c7e
author asepharyana <superaseph@gmail.com> 1783377563 +0700
committer asepharyana <superaseph@gmail.com> 1783377563 +0700

feat: S3 client compatibility — virtual-hosted style, CORS, presigned multi-method, ListMultipartUploads, edge case fixes

- Virtual-hosted bucket detection from Host header (extractS3BucketFromHost)
- S3 CORS headers + OPTIONS preflight + x-amz-id-2/HostId everywhere
- Presigned GET/PUT/HEAD/DELETE via centralized auth (no GET-only restriction)
- ListMultipartUploads with DB/xml helpers
- UploadPart partNumber range validation (1-10000)
- CompleteMultipartUpload ETag matching and ascending order validation
- DeleteObjects quiet mode support
- CopyObject URL-decode and conditional if-match/if-none-match
- encoding-type=url support in ListObjects V1/V2 XML
- Safe range-based prefix matching (replaces SQL LIKE)
- STREAMING-AWS4-HMAC-SHA256-PAYLOAD → 501 NotImplemented
- Traefik wildcard HostRegex for virtual-hosted style
- S3_VHOST_DOMAINS config env var
Coٟx  tree 73fd8dbc7828afeb7c827cb5714bb76900e87433
parent ad3f4ce75e4d3ca66ca55a9fea26dfd0845ab388
author asepharyana <superaseph@gmail.com> 1783374524 +0700
committer asepharyana <superaseph@gmail.com> 1783374524 +0700

fix: remove home page CSS lint warning
Pxtree bffdddab98c0a1de10b5ccc0f7264d9f99493743
parent 339f788dc94b76f6f43b34bf096a19b28a4814dd
author asepharyana <superaseph@gmail.com> 1783374427 +0700
committer asepharyana <superaseph@gmail.com> 1783374427 +0700

test: enable strict AWS SDK multipart coverage
@Saxtree b351aef5ba9803a6158e0fb271eb022b186170c3
parent 2e447ee58ad012c493c159fd879b791515ec333e
author asepharyana <superaseph@gmail.com> 1783374127 +0700
committer asepharyana <superaseph@gmail.com> 1783374127 +0700

fix: ensure multipart part sizeBytes are Numbers, not DB strings
4Yxtree 861683a0c327f3aa2ce691e2b296ba93ff5ba95f
parent ad454041321d8c1d67d384067859c1b393d9de8e
author asepharyana <superaseph@gmail.com> 1783373840 +0700
committer asepharyana <superaseph@gmail.com> 1783373840 +0700

fix: stream complete multipart S3 objects
9Q x<tree 8260996a4de0b4bccb38cbadd909edd0cd8afa13
parent 378a084fd3714635a0ee355ee4a464ab4f109806
author asepharyana <superaseph@gmail.com> 1783373364 +0700
committer asepharyana <superaseph@gmail.com> 1783373404 +0700

feat: support ranged S3 GetObject responses

Wire createGetObjectResponse into single-part GetObject handler for
proper Range/Content-Range support (200, 206, 416). Update presigned
E2E test to require 200. Add SDK Range request test.
вIx
tree 824dca7591c3a568cf536b1300b7f12fdb7cf757
parent 5253fb384dd91d59d0a3bdcf0941b4b9fb529204
author asepharyana <superaseph@gmail.com> 1783372992 +0700
committer asepharyana <superaseph@gmail.com> 1783373105 +0700

feat: stream S3 object bodies from Telegram parts
z#Sx
tree 935dc3eda21023563e9998da74eb7ae60642eb7d
parent 85aa54592ae6618a766c13deb7e089d7171e2743
author asepharyana <superaseph@gmail.com> 1783372657 +0700
committer asepharyana <superaseph@gmail.com> 1783372861 +0700

fix: verify presigned S3 URLs against public host
)SNx tree 3f93cf947048150fc6aacf4f132596f96c437218
parent 2bf0fa5af5694fadd04fd964159dac83cf49053d
author asepharyana <superaseph@gmail.com> 1783372270 +0700
committer asepharyana <superaseph@gmail.com> 1783372450 +0700

feat: add S3 byte range parser
yLx  tree 9a30e3aaa192813d74c9db714d50fec0a43585b8
parent dbdc5ed1ee8138d08c4fb094be95633a39db25bf
author asepharyana <superaseph@gmail.com> 1783371791 +0700
committer asepharyana <superaseph@gmail.com> 1783371791 +0700

docs: plan S3 compatibility completion
kQxtree 60e0dddafd0fa557d1da93fb0e9cc4c425df0059
parent 9a48fbf2279edc0855ca3faadd9aaf3e1bad4df6
author asepharyana <superaseph@gmail.com> 1783371400 +0700
committer asepharyana <superaseph@gmail.com> 1783371400 +0700

docs: design remaining S3 compatibility fixes
%U 6xctree c07a79aaddbba44cc386980483275d04162f4681
parent 06f93e30f633d8f244d0be06665ae81217baeadd
author asepharyana <superaseph@gmail.com> 1783368471 +0700
committer asepharyana <superaseph@gmail.com> 1783368471 +0700

fix: satisfy deploy lint gate for S3 compatibility work

- Apply Biome organize-import/formatting fixes across changed S3 files
- Replace remaining string concatenations with template literals for lint
- Make home page inline handlers explicit via window.* and add button types
- Clean S3 auth lint issues with dot-property access and optional chaining
- Keep GetObject proxy and production/S3 SDK tests passing

Verification:
- bun run lint (0 errors, 1 CSS specificity warning)
- S3_SECRET_KEY=<env> bun test test/production-e2e.test.ts (29 pass)
- S3_SECRET_KEY=<env> bun test test/s3-sdk.test.ts (20 pass)
- bun test test/s3-auth.test.ts (5 pass)
hz%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$2t97sQ}\LGnqԥg@Mc-`-um}?.I@U9804_}~}+ˌF_g}N*r;--hGIz7ɬ~.O. WG_QUɬ?~@eoVo#_2sS^~'r¤I<ܐ{yaJr2l˶nZ_]vg7PBJjAN~^qCs܃\uW-mgbs$PusS*ssԑQlty]F
o  ).fp{T7;jXAbrvb:".>w8oE_Ew+NHM+.aewӟ5RV4XV\pWgoՖsahy"[Z\plBɧl}?!jEIqr~^Zf:-+/p6v5 X쇾x. node_modules
.git
.github
docs
logs
dist
.env
4)xm]o0+,fh)ڨ!aݕevf;FLS>|3hDP:d[e=E1'l~eK^q璍W,-qQ菃`<% l'ammkfĐhk\<a2vl\Bg@ZnIkJNN'Hk+*i8K0nV+PKIB!w1%,1eqelY%/i_XNIAWڻA4_Ghuݠ,ҢSmB~63@58P#)J5d+TޯUL}WVl@xRCH3X`OĹqQ)"'9N\נ"}pvn?c憎%ݷM?	Nƫ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 /xVio8_A`#Y>r lQrPhiѵ$={G#q/4<Gq`\#&I6LSO8ԏ9A%H 0	Cr'\P&TB9ѽ(HHd`ڎm~0fڻoY؜u'JU6-plfL^U@dylVƑn5r4-[)R"3sd8JKX쥮㨴Msgb\͏Vq	bn;Z,Ұ²y	Y}OꟜmL*u]?3fLq=;Ve0pst]26iwүlLӱ|FG'O|J'J#Smgu*j||06Լ^Gй1
^_LlLoT)P.N#̩>{9qyv2听)ЈF: K
fn|q֓Q,E %̿E1(o"9ql0&+^Eqr+hۣ0@뜞V攳6gnķ=Ϝ=lxQPm~)IJ_CCc	wKPh֐xq;5@5wObp:dRZ4"ϭw}"QX	2hXCta2ʩH94tIPR\G_(Dm"ϭvq?vBBwJ`>V@N̏Q;ǅc!fw#ǣ8/KK0
+N.ȵҚUDf0.Nf)F]`n^7wOqNg?&7x_*}U߿lGYۏo>lxB\C!VWd*Az8~4aI![C3?Q5$Ȍ"uLBkD˔qu~f0
	[9`bo3ؠthKc=K//v=Od}mϻIIx& 40000 superpowers SWn(x6n45x@ 40000 plans  N,?g_!40000 specs }A>"ә欍jjD6ix340031Q02025054-IIM/J--OLI-MJLNK-IMaX0G@fZv 1핢|o6D3B7%i|Fhubjة-%5LN,pYN9l&ow-<s]3bcTg` q?v5m9fddTy903mjʰ"j_l&V n<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x}kwFwNAJ-;YV8G$'ɦIpВzhPs3@?ՅmΗqYR\ѯ,t!\1|3/Ç_E2iv.8:+q|2:8ͥWK-l3g{򽜥KE439JX[p;K9Z,Z(RhD1MrE*;⸐\ T·h"VD~+9x4rHI>'r&O45u/ȋ,b<9[ȱ-WY8y^fEB|))# h~8ǯvZ'2/3ڌ$lx1q@L2),Yl4M
zI\x&Ƹ@$A?3pN8@h#V|9I\$bgn
wN%>¿)`]_K{%d4ū}&`9b/%Ke) 0#7	U1x6¾u`"1@4d&}Z$@<hhڰOx59z KtPNI&Y4%HpOh
 _bݦ?k0h: 1
мʒ_~4/2$,WߊI,g"$XoAH &xL @FDL#w
.Vb8{4]_t,;0Sf&֚AH.|ag$F)av	D2aUL]gv\.qv/@b,'1MH#}?>tVr`FZs#ͤ iWCGC`bP qV5"|.	f]@"@\
D	PA b!/,v ;q.De<BIJ^ .`"΀PK"+"1$DI}B-($JP&liMNNވ{9P8JF'fdD, 0<1{Btp^ddaUyf`tXZb̲h@C$0tY<U2H`8%4/tLJFhz$AN^$%  ægb!X䱑$%g	HFA >)L[@쪋.@^gSDE>pFyE:a҉܀"(WFZd1TdwD S˥?$>CIsݏv!K1E6㱫˙sٙ-|,r Q\8EgLȗrb,Ȭ) ~ġүX&8o\5!UĸUKczD 6Yh| -HaH$9ș9PJ1>tԮ67A㫎ũC;Gh7Rd5QvFY}TJ} wpoN	'kd^dk%"V'O	s`Zx4P]{`h_(5.&$5(	=(a d+bWDF<үb{x X͇2kj4hj?Jdj"Q{WsX3XRҀcn$L&qB2𵱞 F0R kzQ%"0d9o$Kb6.FڢYV&А4\hfK׀Ӥh6s Yx	Yk^׬	f)҃cy-7MUs9
5<FAi;@IǍ: H4B^OQ*LY<gMb+XPh~=
gWt?CѣSFI	$T'7
f=$S	 |mF[5HS,@zza zwvm~<(")\cͬ/E w6>u鋐t/6Lv 3\+?Ho@'l A/_/kT5XNFt'xSvoÒuvZJ)h]@˯,τٟR07{ŮFAʄ-ibD4?riig4d'ɏDCgk_isY]tua/hAX~Qt4ufrqVLdl_'Yf+0'&TI5~wWD[w]<-=,/ d<큸iOVy?D|eh7q!gebT6IfH*#n ÑT|f:=|V~_Ҷ9KFIՆ2VoxKG`fzZ;~+'m!!`8/uy݆ɻ8Yx͂[8MY.
e; ߀k=7]}nY]vQV5]R6b}<X'Cgg0kOK}8"-xlwX!U!ckc;Geʱvo}(=~og;=}5
Ga$8dt8	I+lvXt}m3~@y085Eu@>&:m2F7qe _ m?cY<g~aC|xj1j:U=M doDGCAӸxg*/qk`<62ɼ\-:ղHZGGbKbY-a0I#!eU#!ig~/!CBvZ M(VЁhg
bXt:G4?.\}<b9˲o\p`jyrr5<h%aDP0fkubH3YseJjp$+v3h1Lԣ\Xo7<|oJC6	ļ撪qȇ~(lKr?AIWC0NLcH$ί 1p7
9+Sc$"4c3<ď/#~4&l2F8VdX6\] Nq鶈翼x(4mv6l>9m
p5_\5JBɖrhoK7;K3p0Rۏ#qgr5\`Hƀ-RFp0Z
xۋ mln`ӮЄX?r@N/j;9fmk >kC5_D.R\.oY
JJQd!oxtB@اG\08[3}⑘>iw}~Z<F?oDS5OoUf{5p՚i,O@F;P8@qM\h}4U49C0;r
w%ۖ@`U	KE)yEaa^\eMQ{6sX
I 
憹,)عu *D?η4iNB\Ez'$m>HD7QH47n ߣ#"bn ;Q0W u"3B}C"Kg.IKRsLenUA0o\ݲ.()?	]'%Z5Rv:5ZBZ4@iU-'R$l((bXǫ^26-_Mw	ZS.hP-"/`C[JŘ bPmBل+Ykw?Y +1 XM#9Jsmt+GK/cZ'@]!iDNKt90
;܀GÙrBf NƐ5o?j4[uєM֟vFӘyhn|HO\.i=KDke)1{2FIQ]	4y;x5A{=GkPrp煇;`8	hrO|PV]Ekk'd<@ύ<].CEjq5ӄWƨ0mۚ|Lq@%z琠,Bv(>|;-VƜOsιK~7j4~tz?t7A\Ϛo '-cGL>I+pVTJw ()dIU3[vc$8܆᭟@-4h=d{m_f}Y62ͲY* ss `Y
=nhd:tkk]$WY/ٮg5=5[KqsKgvmܣ@7q`MW;mXZ@t?rp?}'PBPZAce2Ymgۘ8!R~%@7eN,4߽BӟZjZYfNvx( h#V ⦞$s`W,y!,{sE?}C8|9CkMCu~OdA4wv#G0 0Z`C#dqRKャ3%:98۽	;XtyuB.V>k:d6_LƋP̑q+okr,;=oQPI75>dך(hz$ȅYh`jzRwؤУdR7?-xEn*E|pk %,iZ02@P^޵Z~RmܽOҮ@j}Ғq09R\:\qbCM;TacG1_ ЧcsGBF-W'#Q2!4,FQEI32Uܨ<`rB5 <ꍤ,t?Luv.V|Uf:3I.Qt>iAuʁfZ-Fm%o4as	Om} -1 6<?Rє\sC<&(Ÿ@nn>fۢ*L<I:%ϩ2<FeȂ,-L}VηQ5J;n&}?mե᧸xׯ1@4tQ B B<net?T&ϏU6EfցY6@n)wlJo8ř˿@A.wSw<Rw
݌Ӈc;wNmRQ^Qo3˾ơm>pOѤ	&?msz4fWt3N׵k׶jnk_jzW|]q_|PrZ;+
jsUhAfT7jDX1rʮ
t2cw;QO`Y#q^n.t:nX?'$N5DUl6Va~d#ÀxS>es"~%PF"M!Ī3ʉ)3I:J8H÷ZnzEa4ehmxN;^ēuS9N"fMV@9DۍĜhPխ+08z;m9lxwd0WaVP;42@Ty0Wzn6 dT :nZhĪ?t"mb0Qs-MyN;^SGnas=TÀKM86WPv$ל(I}	ڈ.Z6gq^[lPzsOSE
9\-;[CW0◶ys,XAWuc̸~ɉy;t(a۷׻|=|=;;c!+w~}{<aP!3lc)$J,cnmmn1`iȂeR!_QǾ:NSq?{=IϡZ
+]R7%At;w$:&[GvHwZ ?fe	aYWI7*뭭\ze͝uKc䕲M\[eKѲbV]7 xV9AECT4в}Lny/ 
8C1Y=[1A$,G賸HcfnITDK_ʴ5skt1]XOk^E&8oƛM\hc5ծݭ䞓}(]FHŝtB&ԕ2uF!Ybgrw{1fn:Y:xHso`ID}׬%/##4]|_Uڨ!NN&9eoH]L`<'	Y-_(TcJ:1;tNGs|R"BN+ɽԚ9}I[<βK:M̏rҢOۍɜyHK
u'؆C;E!1VxHs{E)ùG/F>p7 /`a!E(&@|}/>fRKMde>l$PZWRk/7n-E3/HQ90<
f8%|m3 R,𕎁L8BJ1=1~Lf˕7Mè@p=&.c]YQ6ޮ!7,<Ej)<ں6)QJϰnf"r0chH9`)%ik4ڮxKb`ۘkq7bun<~GTeuKy%.
\\FF/h7bDɈM
ѻ+r'##F뮉Tx[oC{v0E}u27 R~9xJL ]ϽܥjA8ƢJ
QݛnrkA_K,yg~bI`9K=k_pqڅ̫NhvI2ZvB"<g":y~a4_kAvY߯R@1]FrZ*|Ny}!7^YP1:W}z]JG[ҖQt3Xm))ى<ù)\]LUԜ!7V$UN휲n|Dٟs-63M5{`L^'b}6?\۞ǁt \"AbTҧMV'TMNp1KŰN2^}ܣU6ꙷwݧYBd}i*5(?~/@g}abp@~(jr.3k)4:#k_Ę=nRRG;\f7Tޘ/R{}-H*xk!U\ACjشTR4BͰs;GΈfBGo0IeXg8D6byAh>7a<;tk_%&a\8
2_Cq
dn7͆:lOkM_]a$RF1EQn2dFn4[a]MD:`y+܇lW&=<{(~}fC)}1x
 Ʈ*[O<_cisV*ҫGZMH&!C531cPmJ]!Rکg?JQ]Oh i4IQtp>FP$N.}x'SNkn.5ig&UֳI^G[4NF :X/T;ee;n)UVv=Ra\77\tf˯Yed[L	^U~$*Y WӁ%ZlL
~Ϛv[6 -%\@kg<x?Ӝw:ోmqٛEC%׭ Rᒘ`ɀ/jp'*hm7CD?Mp9椩V/ӣfg];LzH%ݙ0O񏟩	 J^5uyu2caoYa^7.]A9	%Re$8vX؅?dtFզx;wq3bo̥=SHzrj|_gԌˊʲa]%(fpΠ$J[-fAɬ͆@ix5I}d6߰ѳy'}:t1*/hRD1vW[S:5T$?s?h&ݫs>uyJw>1gq9)u0	漬F\g-pЎ=+&Gʁw5;/^O\_	oUwS/|0"bzE<f}D/qX	~}M2Jy8Lc7i_4GqF̻;S?O0.ѵ@NY*@/ONSRx5JJfo<E<{3cF:4I]YcSuW}Kwr&}U,bD9!sX0o;Ex~KJCkht19n4LNu	>6".({Gy~^hw5;o9P%N2aWM4.vK$|اm+Yо#x<jnewXz>,d_B[k4xa(Q[ϻί'ً FùѺyޥ%0X/<X.u`vҔ\Q"U#wFzzT\8pP9Z*}Vjҵ'Yb`ǊBGE:²%F1;&)_yN`냓;oqmd'BzNԽy@1=㥐٤0)fFvS )*ݧ~k݀G $p&ۦW#"x={qS߶
4%\/-MrT(¡nW1_:$Z& m$+dd'"AW#&:䦫\ywz&*Glr1е5<JJ.AZUl+fF^÷d}+0b]]|3qUM!W;uWkhaMwR"
-Rf8VkX5!k3`ѝ[bN/
RHc lJ$wD>tmC' Z]1u{> $ 9BOq/Q)¶puVQ;-:F3x TZ^hȀ"L@Ābr݈*/ -m*A=k>n>¯C{859|X^T^ z,R`pU=aQAq{c=nہR]Z
jb1tx*m@tLsŚ6n|rN{㬄u8M]'tBR	x;Z3zD3%T6pi%bob$Li~/gN'oj2y-E`]iھwJ]Z{< pT]8zHC/Сwxg8rC*8+5xz ,ܚ$_x6ݞQaK ŊoAihS7%*F:tkuu8}íϝزMa`MTJ#Éyj XlT%Fu5-i$}ƀ/@'ͪ>I9jQ`4KJ;ue<;)"ά	6>TY F9`1qjsFx%9{bckX\rt47SuqcS_X˟4E9X*\=fp)?>HƲ˔?Q%xn)moLSRճyլ+SR?.TSjD5(g_ud%~$>_HrfE[b _[&2K<*Dd>cT|U(8r	Jq:ZQ
ߡg7?턇2<75K3W+>죍9|j`EK%Ptz (WCE.>+M*RYto#4XQt̳X	Wl5#\gC|Ĵ}Q7QG],ER!E̓
+^1{qż2g*EC|ԯơ,,f[AQ
Pђ܏TM}TݜOmNèZ`"x	XC5~BҦ\v|~mm>맿qQqzq4ɤ,:F4Axy:Gjh;Ǆ< _L&:-`p"עwkͿ?c/./VoyLM>& &cЅY39)I3q'` KW3j3Ø6?ۃ<Β^ rb6)\9GR+Rh6}ZnΔAZHiCy	H@(IA=oM YbSg%*}[TPZ01a#s!ՄA}=mWW ߦ!./3	F
k!U6- On&[FQ~ç#Qϝvs5)n*ǜr}]&u5?"t,87rwliJdTSSU.&Tg=9S@:̃V8gK	ѬˠCg; -ڽ⛕|Ef'SLAW
Ceʴw~=۳eTmRbɒNs\:yƹ@!KQوie(#:'jZ'u`<ۈBvxN0;8/?~?!PfcWY=r'>zGW#Wc9DG 84F=2"sCߝ]VF[h;Lm~7wy$Y.G|ĭdGتS7`>>~'/_QLspݫh@c;QGɂY2J>ECF =H1@{UK!gB8 Z#cFW7F[:-+	դ(DkDm@;ۆc݌MS-DפCf985tɔ5GCs Htx340031Q02025054-IIM/J--OLI-MJLNKMI-LMaZ_f{&|^>_$z	H\f?ȳiՌ~RyQgILm{u6 yɉ%yH<h GG̽#<)djutusrR{	aTu1mw4^X<oaojn9Q9%`^N*DvGK])wqqUs̄[ Jb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
 	.x}Xn8}W$V۱,dxdƘ\3bcIԒ`}OKĺ:bq,lݪ`2a˿*mkͺɲ*qtx<?'UUUk6Z8]+Әf-{ېbGZ^F\Jj+JTZN{nA,s8;W[ .UEet'eVBaleln֚&̄.8׿㙸5ac TQ6q֥άZ

stmuS,k2S5'Yd../otF Ϛ@Gxs%cMj*9ώ搥G>-ֺT	\nQV4xUөh "ZTvm
U3 RW?کZyGkJbdp{>*kJE|HT=HS`րmɁ_o灸Z_S]yi+́&y<rb!* J* KzWllPW27P>]Ȫ' յѾ`AS.oq5?eqzĲ+QDLPjw7ƅ \3|ŵӋs}JdpSke$(jy`BSB=Fm)P0Mx8eigoFjYvCB>ʒ^FvbPz)n(fy%6j!s 3nBuP݄>[/K^T^wP_i*UP|X,xLz.$k~RSPW*	jCtNz\]׆ĊPV<GCzsΑK_.zWTõ֘,o $UGEar$뼆f:z7[f56XV0:7egbD߲rB{rpPYD
B%Wz=vP>XFؓ[v0*ufmYMaImvJ2UUٚū Em?nF[U&|RGgg(5-aеuSL		&O#UʝO-74V	HOBàgӚ1aT*խ5S.њ~HSR҉ߢܣD",ǭИ:68ʋOWgn bx[k c=&rOP3q|x4`
43izee\=l#웼__U7T`u)~&YWAI:w,Rڭ|/va;
Ɵ@b{x^$̽o량ԜCO"MYx*Ɣ LYPrγ|<'B򕽔(ϣU>bi.x?CT<0G3*=<wDsՍ2By^_iRB)9ZoB4	HnSmّhOY>?a2Q>DO<+ESJ+$O^>Kߡ#yMOfǰKG0[M3ĩB-Oh#K?¬	<8`U*	7ΑwYWć"|LC<iN
yʵ@lL`VAu
/gDq$).+u(.?`bğ@mw&eIZ"0b{c62A^2oQSω8Ƴ4ۅPkcՌ%)fAlI>$ӗ3)Tϊ_h̗<!9|gGGöxjAfyC3kyDu/ADbqjSvwFB.q@:kZo/e,;-Qa߳qcM=Hΰn%	t}w?yH1-N8MF5*3<R$#&aDeUB
+D+Z7 D!)@,3|a*5
J,ś
-_ڙo	rg.M=HBŒeٶ3~a9JTiў],Aͯ*74>;!:98koah NK;Z?B6KjB$Ra0mq@%V86aw`%ʹrIO[NѠMk+2YF~r4~ń@n?eܯځx`OޮxfSPwWO)HEWb.ׄ^HMy_\-Su~32lDt$<z'Ta{w˞tw/e5P
7z"Y=c۞f<w&6d4\$z$Ζc׍[5EӞ
u?5"Y[TG×lX+4eJWoxa7=9 .U8vS9?(٪Gҕ
O}utl$$S	߆1wJi="2>I7rg:]`To!4$6)DG'Gb\]=2DTwO,y5~ok]UjßQTꕂR!<wy?/@8"FTA}4|CLv^7|9)Qj&OXi|,+T\bkZ)WD{o5=`^L;\vUERGFgxTKs0+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ԧx340031QH/+)fX#(lvURWް R}VR^'aך+jͧB敁Oy\ </o7YV1#"#?7U/$7A&aSUϱ6x*KI 4Q&hI{m=u$E%o6,="|wg{"\VI0QPZS̰72MM*~>> a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)@%.x340031QH*MN-)+)fH.w_'6
TNVdQZZQRǹ&,74{w@jŞԞ?OP'P5y) 5ӪEX{b?zeCf%T+q|f7ie=>SUSYXvյ'_\[@c-/*T]qrFjn"H/)%]<.= Rlkx%
import { sql } from 'drizzle-orm';
import { db } from './index';

export interface Bucket {
  id: string;
  name: string;
  createdAt: Date;
  updatedAt: Date;
}

type QueryRow = Record<string, unknown>;
type QueryResult = QueryRow[];

export const createBucket = async (name: string): Promise<Bucket> => {
  const result = (await db.execute(
    sql`INSERT INTO buckets (name) VALUES (${name}) RETURNING id, name, created_at, updated_at`,
  )) as unknown as QueryResult;
  const row = result[0]!;
  return {
    id: row.id as string,
    name: row.name as string,
    createdAt: new Date(row.created_at as string),
    updatedAt: new Date(row.updated_at as string),
  };
};

export const findBucketByName = async (name: string): Promise<Bucket | null> => {
  const result = (await db.execute(
    sql`SELECT id, name, created_at, updated_at FROM buckets WHERE name = ${name}`,
  )) as unknown as QueryResult;
  if (result.length === 0) return null;
  const row = result[0]!;
  return {
    id: row.id as string,
    name: row.name as string,
    createdAt: new Date(row.created_at as string),
    updatedAt: new Date(row.updated_at as string),
  };
};

export const listBuckets = async (): Promise<Bucket[]> => {
  const result = (await db.execute(
    sql`SELECT id, name, created_at, updated_at FROM buckets ORDER BY name`,
  )) as unknown as QueryResult;
  return result.map((row) => ({
    id: row.id as string,
    name: row.name as string,
    createdAt: new Date(row.created_at as string),
    updatedAt: new Date(row.updated_at as string),
  }));
};

export const deleteBucket = async (name: string): Promise<boolean> => {
  // Cascade-delete rows that hold FK references to the bucket
  await db
    .execute(
      sql`DELETE FROM multipart_parts WHERE upload_id IN (SELECT upload_id FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name}))`,
    )
    .catch(() => {});
  await db
    .execute(
      sql`DELETE FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
    )
    .catch(() => {});
  await db
    .execute(
      sql`DELETE FROM files WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
    )
    .catch(() => {});
  const result = (await db.execute(
    sql`DELETE FROM buckets WHERE name = ${name}`,
  )) as unknown as QueryResult;
  return result.length > 0;
};

export const bucketExists = async (name: string): Promise<boolean> => {
  const result = (await db.execute(
    sql`SELECT 1 FROM buckets WHERE name = ${name}`,
  )) as unknown as QueryResult;
  return result.length > 0;
};
CĶxX[sF~8qPp cǤ$3ӱ=HQ]޳7]@n}bw9߹_v.	MMd0e}o	[4NBKL0#ʁp3 9;
>^R}DW(:O,eSSK> }4o &?"h,N ˙Ij>S8@zkskmWT>R;iuUziQ4 xFwN:sQFLڳ6sReAS(稝pJ"NsB0E"[WQ{ۺzĂ,'d5L&pXj޾34*+756J#<0W
`Nп0hL,KlF+`n1݃L5HyfDX9xfqPVA\Rg58YPMq"\!֥&*moX4r#׉-)ue2k1n45a,kkrA-šJT,ԁ{`]4/_VBm5!uC5rk/ک]]]rx4Ǟ\_8g)WDWtCۛi|M$$6E,\&nTZZN3JhCLWp9QVDP1绐c)ɒ~	TVngkres9}5?۸;l(z*~4|zvJ.Y+ge]'-5-j鴎4r7hŪCh@so!2)395{yz_z>/ͭO~ڝigWPJ=7E-^Ң?h1ƠbO1rǺfzZGHZwQJrg]-ٟ͗C`bom1w#cG}~YLws!=W26t:8jXcPBep:.c	0I	ؒ &Ylkm'JF#"jzx:Ѥ&ebV<x!/RPS#a!*!
@BgqD81enfYv.LpcBAR▙UTY7A97NJݜXVnPDrx}D6Vv,/\X><"øP4*͜'SGz}|W:k'n뙍=gz٠7)RgG̠.~XZoTvֳ.gD9rFB%OiWJ;ZRRobo̪,˪fN&DIxYܵ޼ɘ;ט'i,SȗgJZS)rrg{O̖[7-g$b>7yq{zW?
u˿el1?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 SD`${dir}/../../schema.sql`, // from dist/
    `${dir}/../schema.sql`,B+`${dir}/../schema.sql`,3`${dir}/schema.sql`,*`.${candidates.join(', ')})`N[8xW[O+7~Wґ.$G5 
UPpvM^rloI{:ЪUy73,e-zfi!oo9LYh$IJJ4I$$sX= sLA3d3,JqejFi&YBD}^9A*($<Mte%F{˹[,c[$'8*ͯi)HD4"u{s>2k)_v?oDy@H* MD$@=|o3ށDPz@%J+'sdH7,̧]o8B =VC~:~!rr<Y׳r>-?V,U?TjdrdSFDƒ2e^=(dlwy/t1l@_z^un@ήϋmԠx)5bGY򒤯I0/X<<f}FяH2_QFnU7j,̯26%NQ^5&Jȫj=&7?3g*8TPa\m( g;Jh٨0z##'acwʹ
Cw!@FPyCr,z4ĈU)rnb*>׌<0.ѩXlՙdU5+k3R43ZƱV2=L
)r7fy_g5Sl5WWV+VSˊײQ^!>[?N)W^ *+f酮jq3<QwDTx8̕8:sM9"Kc}c}͚==Miˑ~H2UƣP ,m[y肆Q %.kyvS? &浃&EJWNѸBG|Q Y5/jQki~DX2&iDprhe^DA _&Sy-a99>>Љ멑ڛ60s810XUei6涿.,ZW$)?d63|Y߷*yD@]%T[_H}n%~~sx~R	u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xWmsF_qD+'.IdP&\$tbлu߻w'	i	vѳ{F.sP瘠SYڐ^1Lf#{,=&9e$'pFQ<BJK5W,'w!E-	&\0	)DBsΪeGnU ǷbFBqqwblU䌬(;'ufxL0LWlzĦ1:OWi=bJ?=`踋g*a#<[D;aV_,rʺyջ{ۛe b"N]?ΰa9=gwGtázݪc9:=';)ھ]J;{8)=KcJ[ʝ|SJ>H,N&t*0mگ#jhExg8B?~(ǚ&p	7x-u6CzJp6g5aY&$'6(x8<K/|$#Cw(w0_Kzkd-34᫡d2$EnBtg26&I2	7e*; F%A-˘ԀQS"Ywq3K=^Aћrܼ~SOmE s 2wKI \廸m&nY`F!)Ө}"UVE)?Z?ʐ4"fH%U<	yeؒi}n|n4̍g[qռ.Y0@^i[Ґs<%n5,~o(hJB|}v/i7Mj<XU=>|	ɬ NIMD/_߷<vFQ(+?9ôdmV\j2tN/dQ4HV%CT=wh=q;{S?^Mu׵rSe?ʅj).?ʫlP^_%Y!T{/llBi~#w~o󃏽a<HQ)T)9WN\TC4|~,fykw;G#gع4oeW Nm^0Ëf\Y8Mt Ie#`kxB)t/KU`U8;ΥH,#aqtFA(w6}pZح!);< X)# 9L07).>Z]9?jBqޅ v݁zp݂b_.BJV])dH]s6$F!zŖ%LmKROZ#cxZT *Vl{LTo3؈Df?ex[[sHv~_/4(҉|̮=vYr.5劚@  (aUJUSU<=)	9t AI+XU2˹~W</"__,|Q&bt)rO3[/^v_qZ$N(tAZK/&#Kv3b<j\桘\PO"e`y"=n8 ꉱ4sgƺzFF:#\|̡agdFa3v<>>Qq
D6qixt|( dF}x<)*;aߖ~R37ILY׏8Y,RWmH-X?ߔb'#k\GODaP1?YGGfo؆ML$# S- |%O2:a Ą}g5pk,;KOX'I(:ŲB]9朋y,{O<GY74,L5OUk(r Ib~za+dSE $,><J e,fJ`#>/ۆBy/aPEWlz'q\WI?ue'ĕK2.PTT,Gj*ާ+ 52r2,$Vh'S}[Ylfh-Fof3%2xPJ%|#ڲˬ)+dXţf$0`o~Mɭ,mz)K! Zv;\o[߯	vq|6M!j9emB(UZ %M*Ŗ-
@A"&%!@qnD D<.A;r-g6$4`_3=z/TS!K ]FN{@zMO}UVTKr'KQ"lBB57`c!md"Ӗ"e_',oR4,:)ƅ:q#;P<<>:*3fVdAvhS0Jy_?QP3GqڦA42ja6aXj,8	*7L~0Rl;pCm)Aa;<^nz$2r50b,ү]Zn0t>nC?Q5p==\A7[w`R@^*1</{jt{0)BXUCny=FV~?-k=Ҁ^PrSرreaf̱s?1+?Ys:ۜCpz|lES/H}s5m8¹nc{cBۅ*иtELOX&rkMj/)}ǁ=]::)Bk<-5jB)&ܖ&7f`ZT85]W"?#]7^v#sqF|!3++\q8èyjMtL͠崧{ =_'$UI"y\(b^5@C9q1#8bBFqY_Q2&5RÖbSYԀ4hH-XEƭ2#dX =C~hO iBi 1H)lZvU.קlqͤfy	̲Np,i.4<[q&^4^|!i )S@H0N{lh?Ї!DTкhiHow"[xY'YwGD DxhI_4wB[a1"E5`Yd
emmM_[ޢR
u1Wfx`o
5$[fg*Wf^ϩzN@{2CB>6f*;E[huf#xR?|uE+C5aѠB.g*C֒W&Zy|&l qffIVT7\
!s^z$Ugqi-wDb * `6|_t=~6kUw-qa@)a\0O5OVC0YZadi
T6h&9C\OAOj2.(>*sC\^Zz>\N6`'H^~/?1x)i<qt})öyd.>, x۶8mglP^`Dv6UIXiK6MlՋ6 eb0=BOC!N?c)DYd0]~؍8nr8Fw>SZj`k:B>ntҩYWbhS/{
OJ6uպ@6Ʈ`F@&3JUݳ8ʭ_rJYyį%Fq硯VakaZBTaDx'<jlӶw9Pj5i2\i8#MVR΀huSsqTwPMٴ s¼s pPA|9[U^ւTFD~{/(={7"K	;`J7kn@rD"&MG҆<z%{%2%/5̺"T5lO̥})*-!s-Ѹپ@̐(1h/x<f}vpPSGND\	K!l	+k>8SV㴽k
qggKrZk/Nv)USU;q ƢKJ-im{4/n&)l'nSʏڢ+ѽphK)ڼ	 g~4tED|Y[`/y~o.gqp19(U<9"hX_L0!,.ZbQ{;E?޽dJFW{fCB~T$o7fHCsjاEhjUAynRΜQ9w5uq|qie
7ᣃy`7YGm8|K<rtpooكa{9jbMYlp·T5 ϰЫ/byhu2l ŉ-4w:_8DTNgMgd!aYzMSK`!k`4FlmZehЂxVȭV2ݬdu֪>X`zU1DDtͽqeiȣ+N`((L1O&TD.vD^ʹ_5lvF\8t^*`vCn)VY|'^\@h*S"E{No^*2RKʾ(?S'DM{!]>MkE~T0{ ұwݫ/rw]m/
Fu?vN=]#yjK]oØz(
b/ɇ4{-jTieR1NlhW Au)3t^N*ЙLoCo0!/!Z sJ' PztwT~+e+Hi~<Oމ[	ڶY=qThl> T
-+UΧ@FoƯy~xDwUחHN[L5v5=ؤJ$G**eaNxpnO+wٖF#J07NL)PZgۮ'Z{Iݚn~o.Kjƃ&' <.9ZXNŌB?3nXA/[kpTVäXt5N>5F7XH{U3ȀmؙVhy/)Wk{8*c#qx[[2ӕws7yPC\Wd]qֽfj1nf^sPfct¹@._Ǡ_R$A9.%JFb%^88X9`,2rUg	sFS~:ǃm[|Td[W <wuXlSu*1[W[qtݚ<w6 ]-xzm'FN7 
UӳSzh-"q|VZɩZ˼XE*<>tq)gkwrmжĭZR^篊q\@TĻɗ*]zJ]V{H32b<Z㟊*7Y;,	F`١:L&Tv&/C^ht=[Ê=V1讝&GD>wb+M*
 }NkнCV\{P-:@8Cm yr.iR٢y	FذS/tju)t*;͑SN˛ĸL~V}C@yѳxWn8)xX@rɦ݃HSN4t%bK*I&}>EJ~
A 2B{$XAsțewԡB<3\'spIrcDSF.99$l3Ed)dD0Sq=-|IZ׌ma/DԒ'-2aSm8,7$Ӕ~<h"dSBg8]Od25uθְc%Q3!2Trd+rITК5ĕDe[@qVM	,B9x;tE0ۋDt}wN3D%]^B),/%}JhTs^cq6Gx# s}B8m Xc^#$B~9Vu\XZҮr{(	t$JFE9,9*|-{:ťh0Xih1D`A ƺ(MA|AM ZJ2? @ }-"B 좇y:hL$2=ۜyQ~˗&RzT(+chd{`]UmFM	^{ւeƹ. PBv|E@:z1((1MuݷtHE`2NM"ȓHXkzy[lDL :7M*tR	.:>>FbtegMc/hx=6-k߉aZj(͇
_W"uHw)c|M"gA6<[ c_k+{Pea "ts=wixPg41M]1;IC9SeY\gw"õ]J~a9y_"5S<bl:RlxȴCN(NGӷ^GL<e'G"]$+cWgU\ߜX ,pg<}>?&Jŧ),җy+_lClI-M'pǮ./Ĵݜf㴤̰$0Oy&q"NwM|g`xsiTNˊF0<4#|*J1.4NoX1ky߂ =)aLZj$l9˔blvDئŻp|y=yRC^|>"(hH &VɎ$w؇`ԯIAPgVTz%:ә~nS°XbSJyy^ 4uLp
K~6Tr J:aIfS&ݠ*^y
ljo7w4;VZsA\rU6WiBd.Hp/H2uC;](>#`x340031QHI-+)fYr%o?zf!DMFjbNIHё
;g?o<LQ~n*H]fw5=Ofݻr{bc_);[CKfX[$j{T"S	3?̨ƥ}TUiAN~b
HQڵKsp&&p<5I7 u]EJnҟX<ʴ] ;bq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(xexport const handleHome = async (): Promise<Response> => {
  const html = await Bun.file(`${import.meta.dir}/../home.html`).text();
  return new Response(html, {
    status: 200,
    headers: {
      'content-type': 'text/html; charset=utf-8',
    },
  });
};
b.RŽx=koǵ+EP.$@(%ˉŭ}XR%y.e'iT5wg{Μq%yOf9KzB`ZϘ,<OcKҀNEog#m?I@`f>^5{K	hD<L{¢%s4.7/y1!JDW@)<-z?[Eý,.yAs(+/QaY~0`0TpP7IA9rVaQ+cbN/(`-h[4Myxe6;ADNi12T%/BM	?4+ZyѢ2
F=~^]mKM(<#*YD
OUloU0Ǚnu2}g') y}BZd)vӴg@Ѱ`id,艟\QԀTIa1i&U7c+T8ݪU@Q*
.j[P+qFpk/nU-DMRk	7qdc~2́{&NI7@|%b6*ѹ@+믤WBrd9;>|=>Nfo뛾ؖkVKX W$UV K`)(χls_Yߊj;rz9-Eշ  m|Yyl5a,EJLa͞feTNWԎs1^ng,DpS`u. \`vuTdQXz^*[ 9 ]FM]2@% |l~>mkצּެ6]-EΨ3TX?M9!VH+Zv2Q6+A$q0=^t>@e!-~MAeT/kF/5Nx{M_r	y@n0ysz40LɌɲ6wa^V~C*~BǠ~+%AoLEA,UYl(Hwb$?uAoP(3*<H&	}0[LٳC4i4	і߉)T@-Fl&ʏhk}Bw_I\٫SӞXƈ59ByH0	h^ç/C1UM24l<MEXф~:#RXIUf6RpJ*sf['*ͥ(Tx VT[4?
jZ4eFa): ^!:L1Pno*o#g@tP-	pE-̩#>8I
PWBtIԨA/q3}ppS `mUSZeުe)&$T$[Boi-[AP0/'i
vYc&Jqj^Pb#r'cûBHE`65
tAIY;ib͉Fج\@ГmF(W-!<pKu#F$hΈn`Gwa}]uV|SMpb<i!&&EQzX9fֽpa{w*vH?=^֡:l!|߈9QwZ<;xqpz̈yuմM^p_.G%<0	JA&ߝӴҿz*al6GӼ=\r&D8Z#%+A*q[	al/YܠVx9t: *a+-ӈƧaz-iyi	#y@xDׁ&WUK܂qh~8Z3@+Pr_ ֱd1{B`׉F~$r7LDXYo`DHy̱a'~t&Ċ成!vD#]=}{2JS[Ɍ7q"w.u`Hgnb& g-);}hw|agY$w,nHj"k^.(`;;l-e`sҘ_;祈mɇ:5I!a
v7@4ܷK
ǻdZvLq[yS/.z~2j;j|>C-k6v9O8L[.NLlǼrb0w!WAh6wdh@pт%30"Ga֨z}Sk:8qVKM+4)Sq,b{} _j94ʳcG\q∟MrAѩfn}|pU&JpvhA[C-ST3<sQ KX*ضN<EE"(#ЊCT;,6_K,gBbȣ	VN$a7̊榟(ͯX^eZ~^N6e&U?^|cмLK? @޻ t<y*r/1XQB!w'}+=C6uaS$IumSn86e(yz=`"Ĳ<n'ʟݒclDMKjG0~"Jabq\I=kW i4jxOIՌV1a>^pEH,tekj֢ '0)[ҿUN?E~QW!!xZbc'33("Omu]FqR3r8z s_Bl08qk?Oީ 
S!NJwQ>`}h9
%)15ik]$kVu&^-o;2S!NkӒ;;#דiٔQ7SM(ɸQn[or|lPEՌz^Q\CI$\x `?5̾usiN9'M> X(ᚹ)3t뮩
m- AKBXn'd]_B`||12am.yl-eB簧uǈދ7eWs'-Oa)G14rX@!xb4)Մ{Rhʘɳ|!Z\7^#B&뾆&龀o굾]%&g3	wSK\@O.ן-fe$o ӻ[_o3Y,uƼ^𞢅VJC)!cE-x:ĿnȭfBbMwh_v
fjV-kvL,
ADT,peb7_;~@s\T^
ZHvhgxK&,tBr:+qGO
@k^4h<B]ҾDhA#u/.ʞk23`a90qt60pS@0
fΎByvyio,X*iao68c7Ha=⽰<}&c*]!	9@dB=P<یq4eL/%FYf`Z}GvBs	6GbvIǹ˕*cT|8BT"Rgr+	rȴ(YϨ`z{}{A:Bυ^"D4\DHvnN3\KXMoz|xqx9w=ݽ?b
bJ62egIyu?FГ`MM1KbS­X@c?/D 'ԃqi)n4GƺL08`#Wr٭BXҞmSW{y}Xąbk4D	f\4yƹkݢkyQ뀺H,V`f4WOb_C^[?[~Q@H+]A_n}bU{db֣zlCAS|ym V!7^ɵ6j>rwC&.^)xY|4	BHu Ø6g.qt~ĊWmr%ڭ^>x@VסXxTUC3\@s3	K5Ҥ
QiFJ"mUkZa*ݽN+])O'w޾՗rXQYMZ,sFD{ZTz.W/i-Kf75	z~vzkdE(z8=,9O\2lHb@~B{)Sj>q@a!|[:gG[Ϯ"lSz_ >{Į1fM>+vv!w\<H#{
 ^%a,YjyG 7uw1lP0>sT	IfrQo[UM ؠ@<G0tK*Doiqoemx3'ȔG*F8.Wi^%3ta`~LAN`5,ȿ6Gv@v1ЛHbޘxeH.KG;ZHb,)`Q2I:Ap|zmNheä+L5SE_Wl"(4MEL	XC`2VTN194Ր6»OmcC޲RG(1[:~d<K`:ڠnvvH߈8FOaSEl۟k~vq/̻n~dHcocU-OUǗ9G ׭~ȹeٟ0ߩz	ɢߒ-/JcF;L@#5^
O`ж03vc.7zt).?[$WaXx3Ldp2mk|AhuW}T׽E1X$qC$!h_%Dx89j]ro!ʺDLf;C	qd&suߧgk] %Ӄ?_WZf׸
*{"7|JEa3	<Pha7N+ERW%LW\|ql`vi|JU0[slC_J|z<0@#/Krw<,*L8
>,U)ɵy֞zodrgQ,SyLĉyg~b;̻$kbyT0ͮ	s8z$zbX'veU'1L`0',"TU˂Uբ	/;
-)f1YZ*IpeZ8[c睚%:7Z޳ۿ2;[¶3]YFo
 _9OέߵҺuޮ21ѽdqnNӼ[M9ly"_4zG˺x2LhpPPmA̽:(?I=$Fm'<>GP;l?/DfaոfÊ+V4=-	ͮ~ Y5%T< #)FN)jd|ZZ-nZB"*G:+k>(RH@'Gaq$e6MiPlF<z򞔲ֻ	j]l2df("7}O2(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mo6{~ɒ
Lhk&vmѶZYH*؏_xI,;/p";W٢L0yOpz*qh
d{O2[fi^] M찚|%"D)I6͊T?.	QqWxM3tCz	
nLaU}p^d)w$yx<ad]eN╒L{>)Zf3WFR\z 9EUEƢyH+F<dь#(pg%\,J~Ng30zՕbJf=]Hs\L&F1 {bY'8z
)<O,+fOW0\/XUׂ^\`QIJa=}^$[=z>(%Z	 B
۴߿mGoҒfb'Vv2qM/	
	:[g
LJX(W8n( cWy 0ȒﱲIReiQ潀ȓ+]Qr~=>}w-,URbbڌ@r4(zPJ1"*V sNFj)OI:U_:y54#?'p?W[KRZl9so$W 2|VhmƑ ypWKCJնP"D+&C3B|	eGZRu$W
Xi]G.wHhp`>G5[nh}@>jS/(CG=Sc\ N]>XԖy.ܻmn&ioAʜsj`f5;w0̸	UJ,#\jݥpLz%Hԡ5jcB?:?<+Â\o8'_>@`9\A-zzחkE&ۊYC^kd59"&h1hn:3[%r!BjEV@-乥|eXh_6į&g*&DF('L!-`2^xiDtPxMH0-v/SR%?~I?JUiF-9Ogǳ_B(Ŵv<69GfӌYe`1\LWT ܅;*Q%QɤФ,OÕn1\XZFe5:N^;j_o[Fm_J<y:6N5@OJ!8$y)TZ%Wk0,dqUN n\*MNId;'伴a{K5,%׃2@ Fӗ{ujtVj:UyP=Drj3zZoXhR,$E,+0xphêR&u
9:hWc%wǎ+'?MQurRɾ5B b2mG%-]i8l+q	4&*Jc:D	ﺠCY{ֲUjqNW#2^^J'ՂbC2dkaNϵq:<QN'sN7h&&XLkd iL.GLhIՂ|߳\nbdxƜ69ͤro4/,t[DeިTӥ?OlJDwt6yȰ= U xEKOO
8F6}+`ڽvuxFp]63RV50<*0@9DvV̿y3ff`ۏv]f3bvpܼ`eκn&[+s%P0?WS\'qˬƉ(xLÕeg5cgU>|.!xe3x~{^jIC4'2
&x}\nz1YVlB~p^vlMjTKm{M,rW`-g#لYvA?j<smۑ}-@/5׷f
iКnHvtvȾK?{֨yfmNU	Us@s9 M#bѵhh7yn
u{K
ЦЎrI;gx>-EэP#nm}mƹ>210<T&ݫqfZp38T6҂9C_ԡ4l3$JkvHecz)oѲ|~R+=^Xrlݪ@::yw&	(rCWGoΎ+5osbj?lIA|b>H8t#{lkU@`wK!o~mS﹃]Ney[ߢrʛm''Howك.(I'VJXޔ}%ݨPlMV'޶l~fOnn{އ`Go}O3Ҍ[*d`V3Yo"	@O^$YPiKv,~sM%9iT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 B1ѐշ:.>)KeS~=ԀԜ\]2c_9{+V8fRp8yPtVqIj@Ֆ'8% CFg6o.U[	KӳN3Xܟ; #qּbx,// 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-;ئx340031QH,-+)f6A/n.5Ѧw$#51%a_ܿ}١RKtKRsAj7܊0DWYgPEy 5V^?$^64=mf<TMYfQIibnF~q	H)SRUu}ҿVmFjҊԼsglPl:բiZ; j[xZ{s_Q !AL2kb+(9m:G1 (ݳ{/dǣzCnnwo]XE$Yŋ93N==*Iw,M3 G<0e_*@GH<J^\'Eq>9*ME^QSlvvxwH/GEz-WRUĜE("z&gz9OFۤ,9(j%DYY9w'GΡO5z~|tt~)ΰ/+^ y?JJ6Ky?>pz˰HU_J0,[3f91%(}Tlj>]!H^6r=\BY,.T"C+`<CXKs8(
x҄2f r-qL "_$%W\,wY+-7,)*լJy%WpY;~5n-9V@k8OVxT*,ly3yU>{%+*qq]~ϓs]KacZcѮk&z<eɮUTuI݀th+Y/ü'['t/9rdlތ@(=iE;I6giňN輐An;GG-*4.rC,%_
lS
zP$1PŒѥs%Ԣ<_OnKW+XU"yLE:$/̭N0&	5
9ߒ% 3GVN_Q|35=L7
c,`Co[o?  \Qvo2d]0{AB׫۔'gwoz>8lQ[8j1y4Jȏ3UcY쪊CQ꽅L=Ԭvc5oS" LH2aDmz0E׫(6Ԏg$XgI3	[Ј[Hsŋ(:dL܃	e4gD0]KsQåG+:piH?5KWP|Nb^{-ueW{pVl x56CW {T Q=dĈ%,/PH|pêE Tρ ZRP0$.Y8I?#U\t($]kEʣHY ěȻ6ZZ{	r?(2:EBdo+a^(m/8c&JQvFX52aр-Q.^ ?*`~̫:
#0KdlbLybpT?@+OA80e8:Xأ{rMyބ9zs&3x]O[fe{콿9;Ysɬ^qPpOM@!C2.%;>O:6+"њKz+ZݸKU5S.A u`z29]k:0RA;efg&5n(wi5SN(4ʮI_%b򗤊=>VB}2AO$U M(դ/yE쀾I`.<Lkr1'$G{4A`@^|6"UI Iˍj9DrI9@1.$03;߶N_FӇO+ds?c_MepMV}d|~v<W{\b3"+V"l-MV.lː3^-(gbr-rۛ"Mvھr,\%}(h۶Kq/Lffw+dFFHx.stKܹh+$6!@5Ly$v;A*!ت?MK>gW=0*]+Ilf)YK:[]O"߶ 9i2]JB5oR^ Y#NoޯӍ?Ϲ~7bKGF59k=g)F$
ԃ<ب_Eed^yJgŌ	^G~@ NyE%8c]( "_J?tՀU}ШM-A,YCL|慲Ͱg@$kL4WxZ0B#McAwnWVw-4dXz,:u-ijbn 2iך![ItTJGHzLqE:tCݏ*SXmԮI$KԮ>"}3cIw{T=Ēfd<7o_߀~\nw4᷵BQ*9ٛz3#uz^T#X>nԖ<wD(f)&оޕ>>n:݊E̓X~Sl}(.Opl7SlȏDvmy
(emuf#CST|1mjnWUʙ&iNZh5t|ѿ_<zyißL	clG[s}~$cŗc3˫O]m[QRö&:]X8)O{v˴YZ0=RcHxSao0_q߀
jݦnRTTU3YV; $վ ~ݽ󁫅R%2%WQF*}5V9aopx1rV9yb*sohg*3G4EYz G	a͸_'H͐g;$m-Jn	<ƆO4rZ665J+p21Dȴ}+筡;f'qx}yF/-lۿ968@9ck|1Vnlmq?K$ӜSBSJڜw:\MsvLFYV¸wwPs?R# lvNc|Isfz$^,Lvb2$"bTzT`4;o\K5#6U[_~(mkAY'0hݽʶf{veI:/nʟb"xWnF}WL#"c4($Aݦm|A ZQCEeEgFRܝ̙QJ.`Vkh!F}3e7)e#
A}g[\8
$~Q	%-D+N3 99f5ZX:7;fD./+R:I,j|STP^p7nb`klsVw*K3$_@y)X~G~]$IQUҦiUA޿YQJ
+CJLdN(Kx]LQ%BbA&3=&-k)tEw,Ѳ02X)(g˕EbSU<GBͬ˴xz}0d[S΢[XF˘m	)|K*&$XgMǼ%jyDe}ˋ/_N&%ІbM!kfUdUN+":  SL}WҤИQ@5Dޤ%JF}=IĴl@%pv4xaZ2PpM'P9+R&j|:(Qo+$?PXu
es aP0>PHQ$YrܱzW$#5QxuKb˜bȶW4i~sۖ/{,/'@Y{SF˼s<{v@V!bW 1l2u^)Vf$ SzM?O3$J-rOȅQMל<X1HT PnSMehq"IKM*})ISA )a߀@k]-\t`"O=zoRHl=A%S<uPi({wݷ?ݴ7j9nMxjcgb6(nU1?n.ޠog7	?|g;%?F=8ub,QlP\LI@8.P6kL5](~Lsa(Sa ZeA`SqLZeF)# үq6,EZPC;OIB8֮&W86'#~(~	V0X\B5(Oկ1t۩.Xf
xa¾d"y]Idئy>oxexport type RangeParseResult =
  | { type: 'none' }
  | { type: 'valid'; start: number; end: number }
  | { type: 'invalid' };

const DECIMAL = /^\d+$/;

export const parseRangeHeader = (rangeHeader: string | null, size: number): RangeParseResult => {
  if (!rangeHeader) return { type: 'none' };
  if (!Number.isSafeInteger(size) || size < 0) return { type: 'invalid' };
  if (!rangeHeader.startsWith('bytes=')) return { type: 'invalid' };

  const spec = rangeHeader.slice('bytes='.length).trim();
  if (spec.includes(',')) return { type: 'invalid' };

  const dash = spec.indexOf('-');
  if (dash === -1) return { type: 'invalid' };

  const startText = spec.slice(0, dash).trim();
  const endText = spec.slice(dash + 1).trim();
  if (!startText && !endText) return { type: 'invalid' };
  if (size === 0) return { type: 'invalid' };

  if (!startText) {
    if (!DECIMAL.test(endText)) return { type: 'invalid' };
    const suffixLength = Number.parseInt(endText, 10);
    if (suffixLength <= 0) return { type: 'invalid' };
    return { type: 'valid', start: Math.max(size - suffixLength, 0), end: size - 1 };
  }

  if (!DECIMAL.test(startText)) return { type: 'invalid' };
  const start = Number.parseInt(startText, 10);
  if (start >= size) return { type: 'invalid' };

  if (!endText) return { type: 'valid', start, end: size - 1 };
  if (!DECIMAL.test(endText)) return { type: 'invalid' };

  const requestedEnd = Number.parseInt(endText, 10);
  if (requestedEnd < start) return { type: 'invalid' };
  return { type: 'valid', start, end: Math.min(requestedEnd, size - 1) };
};

export const contentRange = (start: number, end: number, size: number): string =>
  `bytes ${start}-${end}/${size}`;

export const unsatisfiedContentRange = (size: number): string => `bytes */${size}`;
=D5xu[k0)@llT)`c>ۃsۨa1䔉ﾶi\~XiAI-H=< 1w"3h?.cjaX{RqoBQ/7OPג<~VOayqјL18،˼Y8Zr&1nR~i8;Ceou)r%@]*imS_}!͇fo7hX7nWj݁DP2'ARVp\*Ж'%xʂKS̵;:yU&y. :\5FCQԠ
Yy*qp:6DgmP+E^F%W\5I9\ݦy*i)SxZ{o_b!!*Ea29;l_CSkqNOO^{h "ggfg5EҜHg4ȚܤlA,ǽkg0Xf%vH=ɀg'Iv1d<1͡ЧZre03v?&4̜=E=*]:h(qf{**}c1OgDSy *"{r)h4\#]FD4%ߛȫ"Hsyh&XcfdkA؆ĉ^	R
n¿d}q5SAp4wtv/͡/<ܛ7`~-M'4+po<vl,?gN?=zr{Rgc%ׄoL?3!HX)d&r;O6VesqHM[`a'/HE=WNa,U}?0-klmC_j"< 1A=93S#]W˜H\,iChK#ȃʪ!pA+%,IMxu	YZw6f,~?HZq;(jmQ\ЌF"[.?Vx1#bif;ThUHV.%|U!_jJ!a64>~TQ5C)6PA=pkPqL]Q`eaY@ed3	LS$yrE\$go*39fq
}}'C0ᕻ9EB}O.tPD9ԟ)̓MNO$#J,Mł$4-k{֭鳩LcZjNl8o5"|D;'b1Z}q^ON"Mkp%ґ|*HPnJet8j$mSL7GUt'[Ѽ80aBh$bkhO@/ۭy)i#*<4
;^TUw8?Дq6-{h޲vmNhh(>c`0akUৣZ>rO$k&4I+:!V"WEĩΎ?ղu+_Dx|}<aBp}TdPUd5gG:N"勨I5ઔYլB֥\i+5dq:)j:dśls wfI/N!S@2,UA8cBq%b\?JX>uGel wOiuO@7ҲJP7n̪+U{nﻗؿwC+Tx>JԯwuVbifSǅmA2yôD 2#!lt^-b;r	75r|c>)8xuM ^sK]ۍlʒ归+ Fz5qx(Lh/-hEZը~blyKň;j9r+ҠFF;9*okYۚ{"*9fr	Lιc-,85Ro0_ir?/*aBȽS_~YMNhҙ>`^1^}ܾL=VƎҌј1nS[(~B@ܔ=ϗ	`C'IB
Yю㘋{?C0%)#?7_ kE+6/{'iGdbYsnPB<ݏ"E/~8.98Flt
⤠q3ٖWNiA=W<[##Mi^\X_xߺ!CȉVŏ辖Z{ ڢ0#WըK:xWxNW ΫG<*jU[k0q q(obMa$Jps^,U%}92H.^#dл Q|HUJbkK=+Ս4SnmEwZ5VT?wTpCPZcAh ~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]6x340031QH/).)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ሥ[oT֩_z(J,ID7ǧY*˗=Wټ'ìPƺH.yOVE(OJM.Ujb.\nQμIOA*E@#	ɧtj]g{(1/ѕ&qxQKlD47<yg9O/Sr~$//dT'ʔtYl18)P%9EHsyZزRFGžI,M-E:kWBϲ*92ˊC5'Un[Ty91.L4x<5I7 t_Ǥ[gW%-(yfJ2fίͺzX)  ҦV"{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<rFNk8$@Rbђ\ĉ5HeQlH AUy݇  HB<3T	t><}E?#H&'WN=Nu=hؽad?0/a7=?Uf=r|-Bl^}$߾}c׋X8}
9e?Nݐq¼rCC«TɫN7D3lHEFQL:)g3Q{2݁Clxtt!bc&wJtGJdH`?)3Ho?'H]ef5;r'F!0p< y?t{B]QaO[x8׿?oQ='Cn&: FI` %kTVOޢh@P:rOilϿ5-;;QzWbk"Y["#eiJ#6X(9y
w1 _S>bpIGlgh6??C:[H~,"¶iHp= ,ŕE#`{`Z`Ϟ4{? 02bԌx8Mh#p1٬S!~dwB` , V'2,@<8+639͉btmsȮ`:Tdd`ewQ`L|2@5vi\;'1nKn|Ȱ"{H[KoQ)h'-N?Q.%!%DQacHj\mI`PQa|?j* nO=stLpsqؠBNOR8t!
Se-*Ae!M!ly@}H8Ei;'/6XY;FAO{{ȴ^pWfͻw^SUu+@e7?4	 sEn
y@		/
+"H̪ҽH!2Gϓ{5mo!$R8eTćXjLh&9JcMs^Ν)2ʣjێ24И>t[oׇp
"R侯0:^W'9@%*i&:> Hso[Pw'E!Х'2͙;8RA
c?/y6Lޛ_VRgXy34!˅m/z.*BMg4y*o{ϼL${`R/e7_.jr+WS	l qoD|+I,ѶbB#)!\H osl?zOC:%z#'@zvdyR"pʬe|x"*HQ2A/DU	KdI·%, d*nnCG&(X`x*#Ƒ,@0h3f-DO2$Z׊lw	[dAPIGRą
~{*hb2(xXpn*'ⷈr'hx' шUT J"Ћ263ǌz7(`Joj
֣kn-JVO'B-KNɭ&D`~쪍vm#9$ (<%=k4 V	'..o[q4tn7T4lwAZ);"p"f#	W4bhb3CGHqbfVS%1	)@ yԻ;4I,kZpܳKS&;םn!cW.\dЬW}rk2fQm̼ag={,f&BKs
9;QzC'#ڮ'bPb]݁< Vѻs4+('9|src<2uZ2$y̈́Mhpc.Bj5kMR@2m
b 6y
"{RÑ+1#|P/oC>tloU'4m8!f4K=܀gˏvt(Z$5+&Ie94q=iZ'T(B"z=.H,H!NPHC/m.]$`sL4QQ:-Gy-A\zF3ɸ6e9]g[VNﻸYv wCb~t-BpQIaGbOH<aHP~E;-IV &ZS.Mhkwq,#v bgCKp<~;S2>Yb?%#vÖKH7SVKB,G1үۇ5nm=oFsqx<P#q32|^.W*>4L)⸸$HQt=7$<GB G䦇#0'O"d;(>-ng @BRkQ\L'XbGx#tY/޺*]tO}Sx)pS[%˷6xvى\Sϱt𳩓<IoG3r[v,D,걡{hpk	q$=^zϏְy	9{GR>>kok+6;:F;qxhRan) Üл*[ɓO>`5Ǣ#C\<dT5o`mB_Heg,Y`C8H ^s%qjd5Xc05&H#J=ɭ=ɯ==/9eekwtdӼH*X/?0Ќ$j6?ekGYӰⅆLV,guRJ)ǖ}m&O0!rqg)I``[cHÙxFb	j(uP2rҘSYڬZ2&Ƶt~owo<r|d
&b>4('\/ii \G3c1rt}B~4yܡ3hCA;.+Mn|mީdZqju(Na"Vc##c:Nrm<H,o9ޅej} i̞QDF 9i8ig'hښ޸~a7pfr<o'3)^v\~|}\}ݛ3z{b)hqgd,Pا+׉{8(fU=eB>uc ,Re*~M|.Qab"V_z3X_6KWS;A~QCy
Ͳ=Sq`f}_'swwZw/S@vs>M9i}+9x*XRKWhh|]1%ļ2Y$jjGi-_8R"HMpUP@VڥR <'wnwI.9K~-oggR0e%aX+aonc|-UNB@Ep|,_
T5>)z5
-y4dz,Ҹ{,{GBbR 	}kwd(=Gܗ[
55 s[7i|J	.=x9"MaSժ#1\K?qC+$at
sXQKK:=BQᒙ{ێEDX
Nps۳o/h<Fo/Ϗd$q;ѐВ7gM^:ۄLPw~g	$57CQ*TBf?;8	YP=:z)ۥ-a-tB]2Ylo5sNZMbW؈wk_W.Ol^l6v~Ka|wwgm4_P֬NszϞ%g+1jb,zC߼_7}&Mh$#"rBRw,`qejeO,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');)@});
@'xXmsFίi$زC82&	)P,mDb<;"@63$oosDiY0ǂt\	ِ9K"bͲx(޽ж&2Ēؓ`cuOd} ,p1"c0$bB2'zrg^/A{|УrƶÚW8sh,||2xg][RAhS=B$ȍdDEDo~5 {q҂Jݼ51s.Lk9qTf@.pf'*{TIL@}`"pmշ6=}1>NNbp亮8H-X;y~#"xo`xӐ {HpċmHlV#EɆFImeI/K!a&	ri)pVnQgD;bKQ⃶ƞCom%Ug񘩗9<CC-suӬAHn'
+uB!萉:TdYT#:$JЯ4 Y?ϟte1H#J#/aTu Tx˭b|I;č镵&KR挒-F(ȺWpWBVqK# VDCԧ|ݔ~~""ibU&瘛(2D(d?A0w¤l|ϰLJ
ެ_9|I`-tH`}E,v*)K`*&(3 c(ѕ+_{Bq#L}ILTށԟ#~X}>	b-#MTT2KY ? :$#jSVӮrm)A Qd9Sު8wH48=	ԓurx}x6ZS;<{E[->Mo)lN
DdrnPMiS 7djzK i6I
-tSC!KDJPQ[f; 8]]0Fe<pM4W7+{eh+6
Lwe'4$-(5-@O2-_QF#^
{ww.p,jT,7fK47uf9m8<۾NxrXtoS898&8򫜈.fS<HG7[b>H :bfMIf-_p3uUÂ$JfLzCt/}(]pܞ}Ƚ-tV?'V{xm|W=wg-[6>9yq翌5KY
7eԢS3TUqȚKM4TS#MmX5
%:q똳ʳEnq@XF5%YS9fqttYaiHLYn40pjNJdil%UîaP
SONe̹g}	%~0c%,}Ŀz;jrx'c	7>2nf/٭	Hnkn#C8ҷum}KOnoƯwe:x	import { afterEach, describe, expect, it } from 'bun:test';
import { createGetObjectResponse } from '../src/utils/s3/object-stream';

const originalFetch = globalThis.fetch;

const streamText = (text: string) => new Response(text).body!;

const installFetch = () => {
  globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
    const range = new Headers(init?.headers).get('range');
    const url = String(_url);
    const text = url.includes('part-1') ? 'hello ' : 'world';
    if (range === 'bytes=1-3') {
      return new Response(text.slice(1, 4), {
        status: 206,
        headers: { 'content-range': `bytes 1-3/${text.length}`, 'content-length': '3' },
      });
    }
    return new Response(streamText(text), {
      status: 200,
      headers: { 'content-length': String(text.length) },
    });
  }) as typeof fetch;
};

afterEach(() => {
  globalThis.fetch = originalFetch;
});

describe('S3 object stream response builder', () => {
  it('concatenates multiple Telegram part streams', async () => {
    installFetch();
    const res = await createGetObjectResponse({
      reqId: 'req-1',
      contentType: 'text/plain',
      etag: 'etag123',
      lastModified: new Date('2026-07-07T00:00:00Z'),
      totalSize: 11,
      parts: [
        {
          telegramFileId: 'part-1',
          telegramUrl: 'https://telegram.test/part-1',
          sizeBytes: 6,
          partNumber: 1,
        },
        {
          telegramFileId: 'part-2',
          telegramUrl: 'https://telegram.test/part-2',
          sizeBytes: 5,
          partNumber: 2,
        },
      ],
      range: { type: 'none' },
    });

    expect(res.status).toBe(200);
    expect(res.headers.get('content-length')).toBe('11');
    expect(await res.text()).toBe('hello world');
  });

  it('returns 206 with content-range for a single-part byte range', async () => {
    installFetch();
    const res = await createGetObjectResponse({
      reqId: 'req-2',
      contentType: 'text/plain',
      etag: 'etag123',
      lastModified: new Date('2026-07-07T00:00:00Z'),
      totalSize: 6,
      parts: [
        {
          telegramFileId: 'part-1',
          telegramUrl: 'https://telegram.test/part-1',
          sizeBytes: 6,
          partNumber: 1,
        },
      ],
      range: { type: 'valid', start: 1, end: 3 },
    });

    expect(res.status).toBe(206);
    expect(res.headers.get('content-range')).toBe('bytes 1-3/6');
    expect(res.headers.get('content-length')).toBe('3');
    expect(await res.text()).toBe('ell');
  });
});
m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'import { describe, expect, it } from 'bun:test';
import { contentRange, parseRangeHeader, unsatisfiedContentRange } from '../src/utils/s3/range';

describe('S3 HTTP range parser', () => {
  it('returns none when Range is missing', () => {
    expect(parseRangeHeader(null, 10)).toEqual({ type: 'none' });
  });

  it('parses explicit start/end ranges', () => {
    expect(parseRangeHeader('bytes=2-5', 10)).toEqual({ type: 'valid', start: 2, end: 5 });
  });

  it('clamps open-ended ranges to object size', () => {
    expect(parseRangeHeader('bytes=7-', 10)).toEqual({ type: 'valid', start: 7, end: 9 });
  });

  it('parses suffix ranges', () => {
    expect(parseRangeHeader('bytes=-4', 10)).toEqual({ type: 'valid', start: 6, end: 9 });
  });

  it('clamps oversized suffix ranges to the whole object', () => {
    expect(parseRangeHeader('bytes=-50', 10)).toEqual({ type: 'valid', start: 0, end: 9 });
  });

  it('rejects multiple ranges', () => {
    expect(parseRangeHeader('bytes=0-1,3-4', 10)).toEqual({ type: 'invalid' });
  });

  it('rejects unsatisfiable ranges', () => {
    expect(parseRangeHeader('bytes=10-12', 10)).toEqual({ type: 'invalid' });
    expect(parseRangeHeader('bytes=6-3', 10)).toEqual({ type: 'invalid' });
    expect(parseRangeHeader('bytes=-0', 10)).toEqual({ type: 'invalid' });
  });

  it('formats content-range headers', () => {
    expect(contentRange(2, 5, 10)).toBe('bytes 2-5/10');
    expect(unsatisfiedContentRange(10)).toBe('bytes */10');
  });
});
Pxn_1%i*pQQ6AE֍GH"mE_[i'?_93&N6Av#3gmmNzodO&"/d7 qIpFĜh:'>d)9b\?bNAD= .6#.hC,IP.!A@%"
9	#%3CvK'XőW	K},<'4>$4;<:Lؔjf:[aZ6eryp3b~rs!b.,dIC],Dx91MlaσK2NC+Oww]5ѓ/d02x DKNI (M&&
K%%,<G WNK@?m4I1knb6m"$Z#-}t:OA~LL(Z,>lD|9vJ3V}܌!ý*&cn=
cF
^|X|s yi*!7{0̟"Ez*R>??_	 Gs#^|G5S~w&zs])]{l: (4z"CB|U7dcP.$w=C80p[]E>m+ѓ?hT'W|Կ$(hƙtJZNo])Dέpu
4NHއ=%oO|آ#a3I}⤼(hF76`!,\E'bow(q%+DN+*Că$W\=O/
 ;;Ҡ3@\wVPr,&/	q[ߣJc#JВ0eK'tbWL^J	~AT=%2%dQ
 \/a꣹u9XWB+({Ɩ1C]u#?,vuϮԟvļeh%54@CP}Es%ȄlػM;l:Eۅ@ÂV0T."HD#(j6I!5	ܲmIjyL;Y{W,d[x,Jm_(\\n j>B#vԋr-W?=0/X#Y䱓RѹXڂ".$I x5	t>:V*/𶈈 :Vv4u
ȭ0s'kź2\cne!{B2-&	˿fZb£Ays@?&aG9]WDLќ/2wn+e(?r5ZU%TSZPFdĤ|*4%L%)D*# [kH"	孮9SEUr}	BϩqzEeHZ\[AJ[JR7ʾܻsOR9ߊy	;(ĝ_sҰe T.Q8RSv	W`eYESȓ#b9sf]
' c|yGrׂhCV 8dX +̏j},Q gڬL
p<L~r"NkV9&G],/Ϥoj%J3ڵRdDT@樆S+YM:(2MtSԈE/ $xF^5;!lFee%ƢLάхh{1{%tQlLVH=;7T"Il3n5~/FYXXɯjxܯk:7QmVq3&],&ז\WRϢ";{Mg&KzuS`oqj0pMUH'rs.%j[K֋@ՑiTv2YVNžx	#o3
D&O8oO
24llhuo.h,2OiY(##݁"vG-H*{K? niz?xVR{)cn.ِY/_Ȃf#^I$0WrSNH0g4
KX{6i3
{ӂ{vqW=Wgx+6h.qn\Eȃ=X¶LKU'挣/
\1zFcF:F^uqkx|'A}s$WMcɌ6}>_p|85Ma-*N\3#!}rڻuޯzxZ_BeZuoXO}Ԫknƚ¦l_4l?@KQ7x":g0tl̦42gyw5mIOo
AHXW	sDcOjePWwj
7Vʺv<kǷHCu$/夵榮WY;kyIq9Ve]e?
T"o`6F%۫2#6kN5QI5i6sN_%~Cx:켟|5myΪF_Cҵ=_^&]*Bi;goz PJMjr4%꼃:j:S:Elm;f9f @b&,w
^w3S,O4yȕcRO[Ec5r~BX#ͽͽ6w>mm)qmi>7VhRxH3c쵋EȃGݒ%{%'!VeBcLNs5yT) H'=K²dWȺH:ğm[bqRh \1V1oj5J=ycwC(J#X,UkcO#wa~S,-Oծ83}-yrV뎥dKnN2&PzWv\$;QH(T3,񍮄NmPw>ȨBHN#Ì*[W,htrNn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nx  Bg :fsOJHk1x +tEΣNwS	8 ?ԿSWxdd`%kjxЩ~}ex; $#W)>= ${prefix} AND s3_key < ${`${prefix}￿y	kCx9 pZޱ&ł]C(wBg :fsOJHlfx,rSx] #$x9 pWNO|i.+\Pw.&=5SD0YWaHGx,%a
w&sM,1yVZY .U'x Mjiir~VVa2aͅFl&x[&3Yfy fxQ =G@ѴwctHu^PQd텰xh6君Ftw2naE.AL݅:fwH!%gxxzeC  ulNx '˫x 100644 bot.ts :5ze840000 db sbCb|Β1!100644 env.ts ?Ԝy4iғ4100644 home.html ]וq$sK](100644 index.ts ]+{%
gF8Oc40000 routes ˟3|K`_:խc40000 utils r6wDu	 '^-wx6 :a"Iw7^&n3ođNX%8Ó#zXS%e|x $"h
prefix}%`}	agJx $b6Ox[&Mr f]bIlWX7c Xx3$$b5Zo`oX 
      if (?
(
>9
YisS3Request(
0
7.      },
      HEAD7
[isS3Request(
0
7.      },
      DELETE7
[isS3Request(
0
7.      },
      POS6
\isS3Request(
0
7.      }U
5,if (isS3Request(headers) || new URL(req.url)$$pק[x y@u7EL#QϏqDjxU[lGլ;H=6AM6BqPT{]}@j?| @Wg@|T:RB-Q> EU@Byd;3s{1~SC]W}䄈SML qgaݒp 2ޛ1Ek)l٬nXlqP7p|2BePa+eI˨$NH
aiC#քG-ĒtuKrtDcBY	̈́1iYɒ5iL|*lz8j"lKNV-tm*)C*ۋ{*_jg<Wof_7  쪭TJU2[uWU9znVph;_\ihfrrX	dLA(/tsSNFQ TɄDo"sp/Hkzţs]WO;ч*Tlmx"Xyk{o548^g(-hR'|b|Ibc(<dH5:%/8<0Y68Z[όpw	<γ<n3fyiM)Qxs=zZQևnt:۝9,+vpR|l2z8g7`l Ŝ k+{QGAxĒD|$<*K){ݖEJ`5!{YZϞm#MDP!wj:{ł+;yoOHqTp`.IiutqjRb1H۸[xbDn>ֹίrKI>z#~x=g͕H6iw*8S8?ux˼yȌHV湛frhuE-zB 	x j100644 auth.ts 2JTL@tσ;^Y100644 object-stream.ts Rfy6}R	HuO:100644 range.ts :'J1җfnO100644 xml.ts -e7r]B%M{z8`3Nj8jlDx NM(('x ,!9~&"x~ ˌS&ݼi?1kmqrbAjDnFAQjZff~ eǥ`XZdR
WRQPWF.Y\-n4y9\u^vjb\7LsR+J .ؼs!d7Eb6@wL>̝ɏɞ<yAu9\Mr~^If^ibIf~^H~vj鼭h?y)>'\hB~MaYRi[l)Lܭ9SZ(43E^ZAB71J*nPQ`P] ɁJ{K2ңclcR@( ZַNӈִAJsRKrK3@̜T PԊdJMME[[ҜMj+$A
J3 c5AnWNd W2x 2??_	MW47y_	Y	_	_	Mt	+  extraHeaders: Record<string, string> = {}*  ...extraHeadersJ = Array.from(body.matchAll(/<Key>([^<]+)<\/Key>/g), (match) => match[1]);Xa1Dmx? 3E>g+	z6S[40000 test f/cP+mk.)w-x rX)ك>w쑆e5x ||;*Aĳ)P-flx! ME!9yoóaeQ@x "kiP,313, UploadId: uploadId, PartNumber: 1, Body: 'hello ',P31:, UploadId: uploadId, PartNumber: 2, Body: 'sdk multipart'-3)1/	

+), UploadId: uploadId }))21(
3
	y161F-M>x  PD:!J(C.)	49x! M02U5_m7{abx-kb:describe, expect, it, afterAllX!S3Client,
  ListBucketsCommand,
 
HeadBucket"JwListObjects / $$Abort
7 /* best-effort */
`0,
*,
      
} Key: u.Key!,LF /* best-effort */ }
    try {31"b })); } catch { /* best-effort */!redirect to Telegram (302)// TeleUploader returns 302 -> Telegram CDN rather than the raw object body.
    // The AWS SDK follows the redirect but the target (api.telegram.org) returns
    // non-XML, so the deserializer throws UnknownError.
    // We accept either a 200 with bod+y OR an error with 302 status.
    try {
  -/)/23l,  } catch (e: any) {
      // 302 redirect is expected — that's how TeleUploader works
      expect(e.$metadata?.httpStatusCode).toBe(302);
    }s0,`%&! &0,`%&x)1O
*(via manual signer — AWS SDK adds extra signed headers
  //    that may differ from what the server receives through Cloudflare,
  //    causing a 403 SignatureDoesNotMatch. Multipart is verified in
  //    test/production-e2e.test.ts using a manual SigV4 signer.) 15*— verified in production-e2e.test.ts', () => {
    // Multipart with the AWS SDK is a known limitation: the SDK includes
    // amz-sdk-* / x-amz-user-agent in SignedHeaders, and the values can
    // differ between signature computation time and the actual HTTP request.
    // The server's implementation is verified via manual SigV4 signing in
    // test/production-e2e.test.ts (S3 API > Multipart upload).4V%,=x<ba:afterAll, describe, expect, itXG\/<   PutObjectCommand,
  S3Client5
      /* best-effort */
   
`+

}%
            Key: u.Key!,
           ;F&
      /* best-effort */
    }
    try.'+*
    } catch {
      /* best-effort */
   %stored content (proxied from Telegram23@$f21M Iexpect(ETag).toBeTruthy();
  });

  it('GetObject supports Range requestsRange,$,7, Range: 'bytes=0-4Range).toMatch(/^bytes 0-4\//KLength).toBe(5);
    expect(await Body!.transformToString()).toBe('Hello');s+		M&+		);&FN&)Wo70ox  7N!gՅgħHw(x6 +C-ŝ	)fwɝfd?x.]=n9BP4A; Cx6 :a"Iw7^&n3ođN޳u`	"%`#x" $r.size_bytes as number	Y.x 100644 files.ts _y!)oo100644 health.ts )pvr'*C100644 home.ts $GL&JY' R100644 s3.ts _qRސSSp5#100644 swagger.ts 5O%&
)?T100644 upload.ts gM&҇ 6SCy@100644 web-api.ts ~z8	"F8;L dkx '-Oe@-[BK,x? 3Fn~O04W40000 test uZ.e6u֖?͓.)7x @j0D<߇ z'x y'潋kwN]AoqP4x]%
-%db<+.!fh%l0h'?;tgResponse = await fetch(redirectUrl);
  if (!tgResponse.ok2Gstatus: tgResponse.status,H2-});$*^	e-OeJtgResponse.body!H#5EtotalSize || 52''66B

         x6

         165R  'accept-ranges': 'bytes',
      'cache-control': 'public, max-age=31536000',
   HT3G<~plx! :I*U`M(HNCx ƅs=DE9Rnx? 3Pߤ/^.a^40000 test Lld`À1ړ.)Tfx; 4ͿA MbB3}+40000 utils :ۿ@U=ZP[+x y0t::%Nq	x l("P// Proxy the content from Telegram CDN so real S3 clients get the body directly.m-!c'.Y*.!fM( .3/wt4203:+B6M!x  Y	8K)Rv.x (t1zy{x%:fkIx ?>0]׳Kh> x: :w@j0r&qWM)NsS+Ӗ}n^yӏMԩnBa0
xtт0:// Presigned URL: 200 (proxied body), 302 (redirect), or 403 (auth fail)
    if (r.status === 403) {
      console.warn('⚠️  Presigned URL returned 403 — verification mismatch');
    }
    expect([200, 302, 403]).toContain(r.status);
    if (r.status === 200) {
  L:%o:1#  } else if (r.status === 302) {
  @&location')).toBeTruthy();
    }vD(E9-ql~x a^T(65x? 33sIj|%$X40000 test 06$W*͎#.)$!Gx `JŅAޟ
db4v4yx˼yH6fS{Wg}^}Iၤ2XvhVx i*V?Cl#x $x? 3PQݲQS3h40000 test `&Tٝߕ1L[.)uR;x; 46^Jq,g&dO@40000 utils jS))v/Hx y[*/0xYrXqx >3[/i+R 5IfullUrl = `http://localhost/${bucket}/${key}?${searchParams.toString()}`;| 6
      fullUrl,
      'GET' 
Td!A3	Request has expired/`,N"7Jg?lx˼ykdtrtK2Yq! ҫxi 100644 auth.ts VS,\,LS+100644 range.ts :'J1җfnO100644 xml.ts (t1zy{x%:fk*('vx`NGzsearchParams: URLSearchParams=rams: string[] = [];
  const keys = Array.from(searchParams.keys()).sort();
  for (const key of keys) {
    const values = searTchParams.getAll(key).sort();
    for (const value of values) {
      params.push(`${Qkey)}=${Q}`);
    }
  }
  return params3['authorization'];
  if (!authHeader || !authHeader)
  url: string,}ci"BqueryParams = Object.fromEntries(parsedUrl.searchParams.entries())TqueryParams['X-Amz-Algorithm'];
  const credential = queryParams['X-Amz-Credential'] = queryParams[P];
  const signature = queryParams['X-Amz-Signature'];
  const expires = parseInt(queryParams['X-Amz-Expires'] || '0', 10);
  cConst amzDate = queryParams['X-Amz-Date'];

  if (
    !algorithm ||Enj// Check expiration
  const dateObj = new Date(
    parseInt(amzDate.substring(0, 4), 10),
    parseInt(amzDate.substring(4, 6), 10) - 1,
    parseInt(amzDate.substring(6, 8), 10),
    parseInt(amzDate.substring(9, 11), 10),
    parseInt(amzDate.substring(11, 13), 10),
    parseInt(amzDate.substring(13, 15), 10),
  );
  const expiresMs = expires * 1000;
  if (Date.now() > dateObj.getTime() + expiresMsWp +9const presignedAccessKey = credParts[0];
  if (presignedA1u=  const dateStamp = credParts[1] || amzDate.substring(0, 8);
X:
  const sortedParams = new URLSearchParams();
  const paramKeys = Object.keys(queryParams).sort();
  for (const key of paramKefys) {
    if (key !== 'X-Amz-Signature') {
      sortedParams.append(key, queryParams[key]);
    }
  }:bsortedParams);

  // Build canonical headers for presigned URL — only 'host' is typically signedW"hA) => `${h.toLowerCase()}:${h === 'host' ? parsedUrl.host : ''}\n`#"hashedPayload = 'UNSIGNED-PAYLOAD'#bm$D	$*s3/aws4_request$i%f
w& }&T['authorization']/'9tmipxB GG".authorization;
  if (!authHeader?E.authorizationq#9.7x! 	h;TICo5.b$zx! -	
     	&'GET'});
	Sfx? 3Z6v%dB=40000 test x?tE.)b`[x _{|F?ET(}*x  >㜾+.sdCTxE 100644 auth.ts VS,\,LS+100644 xml.ts (t1zy{x%:fktZGl[x $x  ߣt?p&8,cЬAx& 40000 superpowers n1O~g<?h1x@ 40000 plans @=4]KFn'140000 specs }A>"ә欍jj%g~x;̴ 
9Vx  0f
V9/tb%6^<{ݣ?x& 40000 superpowers Ovi`cȉXu)wڠx@ 40000 plans @=4]KFn'140000 specs ;X瞨. ښzA%g[x{4i(# fABx? 3,=vgcC)³o40000 test 3hnY@].)ãs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	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6 )h
M)hIK))xx4 $"import { eq, andeprefix + '%'}	ahmx *CBGracx ,M#!b8{#x J|z;*Aĳ)k	onclick="4	onclick="1>'./' + _c~ w
part + '/' <B!7&vdisplayName + '/'&-p+'%';pp.textContent=p+'%'	2B(	/8x=N>+\;`fx. $$>y,-	/&ғ5l 	*RxS X4_:`歷zRBm\,100644 s3.ts j sJpnbi_B^mǕ{.a-Z4Y$$x+ Y!import.meta.dir + '/../home.html'z92zximport { verifySignature, '!listBucketsXml,
  s3ErrorResponse]%V26/PartsXml,
 ^e*,
  deleteResultXml,
  copyObjectResultXml#Vr, deleteBucket%c/GaP{D
!M:import {;., cleanupTempFilh!3)$Ygv\x_ OOMr, deleteBucket$A
$
+-!:import {^., cleanupTempFil!3!%l!x  `hQ95nXxE 100644 auth.ts Wmy!s1=/100644 xml.ts 7 ޮ&;;?kQqPx_ :8vdL
gf	ND
JeEe&C; sdk.test.ts p}$\V4QPfd-k(4,x\тdescribe, expect, it, afterAll,L'AWS4' + secretf$,C           //5 body?: Uint8Array;- Q!u(method, url.host, url.pathname, payloadHash, opts.headersI_I+ }
    catch { /* best-effort */ }
  }
});
!await r.json(,Q@!await r.json(ifdQ@await r.json(,await r.json(p+
,-Q@'
?.(m057E7NM?JuK@x1 (import { describe, expect, it, beforeAll(+
rUx ub^ f%stored content (proxied from Telegram23@$f21M expect(ETag).toBeTruthy();fy0x? 3N>ƍ?Dg%P)`ҹ}40000 test ٠MMfux]+Ak.)Px6 Mg"Y ԊD.oaV:.C?$+Ȅy(tcЈ 	Jx[&Zb/ͺL̛n> koXx y)4[20*q^hxe $k?.^+	headers: {
     %23??)6k?.^+	headers: {
     %23?/G< Qx: :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4x% $&KMPO2y(:;kx y"k)x 	OLxo qHR5Jo0®Y3Fp^EVtnw3=J1lC#kĿLp40000 test "դH0Bh.)T/2n
yx 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)'x NQZ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(x HPinterface QueryResult {
  rows: Record<string, unknown>[];
  rowCount: number;
}.rows[0]rows0.rows[0]rows^	*[rowCount;	rows.length > 0;
};
fB\<x -$#import { eq, andeprefix + '%'}	{
    rows:}
.rowsK@rowsO{ rows: };
  return result.rows<keys: string[],
_{ rows: };
  return Number(result.rows-	Fbx! ##, keys: string[]ԔJx C$,M#!binterface QueryResult {
  rows:
owCount: numberQueryResult;
  if (result.rows..rows[0]!QueryResult;
  return result.rows
+r.size_bytes as number	YC,x $HomeP
	o#x #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-cx {Ay:Ax  uFѲ/ylDK؊H3w@x :޿f>(+^ȒN-x :3pwsI<nMBz0x3 y67i؎#2V81x%ʑ]S޸4NWdJEx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Xx  &?7Lmq	MߏWCĥxE 100644 auth.ts Wmy!s1=/100644 xml.ts z<Wfop;5[gF$xx <?=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()	:=6xj `E+v?as!h(100644 s3-operations.test.ts n=BXeR>ALRS`5X=[!"쓂'+x?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|x { 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$GxG f" id: 'uuid-1', name: 'test-bucket'"ናtTt
~1x  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 zx 7const schemaSql = await Bun.file('schema.sql').text();
~7try {
!-0P!} finally {
  await sql.end();
}
/){ldx !%xT X5ٯڛDsK^100644 s3.ts hv;H	-+W2ב]S޸4NWde(Dnnx Y'srco&Sk3x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 ulfx 	O!oDx 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Ci6x	 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[cx  r.&ӎ@IͿڭBxE 100644 auth.ts %JrQky4#I([100644 xml.ts ,̅5'fr0mSsxx GD
return uri\R*parsed._]
G`${h}:host\n`)
    .join(''?Str = signedHeaders; L=LStr 5)x( ==RlB''" },
  })"lwx  vg^6~ȰRՓHw%'x <&/ު:EM
?j{x
 :D~x <+{ȢØ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:+fLx  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2x#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ƞRx +98ܐ+)JƲCF?ax& Nuschema.ts Z/W6{<?l74X Knx Xa\.w"wx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x `},
});
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)3Qg2x _ x 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}Dx ##
ճ	cvx! T@'B]"
+2H	x k100644 files.ts qUd_T+,C100644 health.ts )pvr'*C100644 swagger.ts 5O%&
)?T100644 upload.ts o`9W%M9lGx &&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Ux 74VU:zKhy	tx 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()),
    }),
   )#k1x @@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ԓlhx dV"^x 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wx 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Bx! JF{},
});
9Τ	x k100644 files.ts NɈ%ZvH40:100644 health.ts )pvr'*C100644 swagger.ts \\\dk[100644 upload.ts ?.녹N/6>^:lcx ;;B	N	&~vx( DDa`}\
A<OjJx5 :!IQAB'tNfg%QT^q7\+wШ,j<gx !!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>:6x &%=m>Qx@>{ 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-x6 4yGkǇ[κxw=H!C<ycv+}Bm|Fx i-:}%ty.'A}Bcx; AAtechG
techtech`)$Wz9(x6 6,}+5s{jzcJ#.&1J~^0ݗэIVx% ::techZtechNtechD	%ecx 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+lVx !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x  (~
      ? []
      : "$ || '')
          .split(',')
      .  H58Int	!, 10) || 60000Int$!, 10) || 30?G ...config, botToken: `${config.botToken?.substring(0, 10)}...` },
});
f9wmx[ $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>=Ix#&"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kx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Kx' 	>0Number.POSITIVE_INFINITYQ%dfx 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"lx*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gx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x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!xw version: '3.8'

.image: ghcr.io/mytheclipse/teleuploader:latestrȴtech`)`-letsencrypt`5	H+gx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Hx &[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kx^"+[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>+]x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%<x( ̯!?@JkzKm 
,l-x$ogx 	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 sx   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ax J)!&		@xo 100644 index.ts vQfzZx.v100644 migrate.ts R7Aq2JfJP100644 schema.ts &[xĿB?|7+
Ex 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Nx"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_[x
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撍1xſ___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$Fx<"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2x  

// @ts-nocheck
!anyS	j[x
 #+Txm 
any)B['/api/upload']P*"
      'multipart/form-data',
    ['/api/upload']P?});
*!lx? 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	xx 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2x+ %? []
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#x:i,䌉  5@x lhelper `withRetry50Ox eFqv&5ȧ"U	Ax[&3{B⭛ux&{n0a|C`r)BML);)΃36|]AOO/9?/-3]G!)$$?;5J!A"+.M*.)K0 RԙPUi ,wx OcCP.>7/ux=#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#
}));
    }	<;
?)kXx #`x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
Vx \@:#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tx? 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~윑 : x J)!&	cox 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+)"jx
 Ck`x? <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Cx ~+ӇΚHEH溂"xt EEK checkFileSize, computeHash, ensureExtension, extractMimeType, getFileType ;251hгo
251hX	!*@(x NŎP!CBJ97
t{x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 
}x,=!// @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'Ox ʅzww?cdx' E2"<V
`R
8I`!"{@'Sx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)ҿ};nx kƳh||`qR`xi )!H> checkFileSize, extractFileName, extractMimeType, getFileType Xn	zO
});
P$vx 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ѓ*kJx 	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Wx &"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:rx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bx ?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?Wx !1.1-!H1.1-iCks!xP _2zߥ;ؿaB06xs]LB_x<6^3tvNu[[.+)&
Yx \@:#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~(.?fx) 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ex 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yx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/ix	 d5x[ import { pgTable, text, bigind1*import type { InferSelectModel, InferInser*n{ Zx( 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%] 3x 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Xx+
	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Sx 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뉟 *+Tab[X//