# v2 git bundle
721fa3db7d80cf49e75bee8ede46240346eeee95 refs/heads/bundle
721fa3db7d80cf49e75bee8ede46240346eeee95 HEAD

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

0̭t.
MN* C/%;˝\s-ss=KMJBrKý$47/Ź\)8ϣ/72Ŭ4"5(5#Ǡ)25jj [3x[*M,83?JAXB8,39؊KA!kTv7URKJs+

yeEyy%@N9'o|CGW!?(k Nɭ u3RStRKf$& |Se'@ V\%JrmKJS&Ԟ̭2٘M<(Z$#K(I-ḴR  jMx& 40000 superpowers UNxD騥=˓Hx@ 40000 plans xIeXNr %+UP40000 specs z3uD>Onx340031Q02025054-IIM/J--OLI-MJLNK-IMaq>>4)vUJc
_	tù˦rs'^	bn"ж /39$3l#g9OlK瓳qJ  VCx i-:}%ty.'A}Bc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{tqJyHРWW Gx'oWML-,&(U&f%NP0,-N-*H,../Jq/.OIKM/.OI-J-,,Jh (L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"
```
`Ȫx340031Q02025054-IIM/J--OLI-MJLNKMI-LMa⾤S;I{Y_%5D3hB%6j[{;w{E0AMۘe&'d!=2P򤐅 KxrHb*/;o6.p{`Xh6ڪ{{{HwJik[/TDb$"YO=S98`ãׇ~`^ORfI՝ߞeqX6ص2JɇA5l4jO26ؘJ%	}'Ž]kfA*U|l˗iv[a)^.xcf<_H*4~O׬{ݯ7X"&q@W%ZKǱ,ZR =\p}L\T^8afM[Wknx&Q17H	@hPd44Tc=ێɔ*%(r$72d|tKֿ)ŗLPjFU@sirÖZmJs=!BNŹ:-j.́!OL|ko^4HĂP<@Zh-KE$,o>CLz|jf]%CAEe9NBKހ>+	 ۀ[Zu'CZ<%V2-N_G`|H%DNȯ)ْQ%Lpb5aZ7b	
̏rz0{miD,4=/<?A4PҪF$BK""v|Z(h%Ys1oB[o ϟ490dp=Lu-ȖueN*m(	)Ss9!g3M~W̓"~PjgzWԝii*Y?MF\=g8Oyh(¥?e`NERMZ"up҂<+RYCĽ3MK<n;JZ}^Qd7d7Vx7}y+@k'Wz[T *jjIjbEN6)_Dcs%~wil9TcUw+`8Ӡek ~Ug7GS6q
r *r^X R9_ӽ eEb{lFmSlŰ|򳸕B9(C=_Akн.lq~38ÿ97{/`RVwԊWȏOQUJ^y<fE_b]ϟ2m cEEqU*WՒ&:XՌ:4D\|NVԔ!5.H-d%GzvJI	}	%
]ayf+SQ"r8PM8h8 d@U/K&yYk=eQUaNg%j&D	TZVQF:jlqJY9LǝvZ8g򏺋ntsO7BkEщ@	l.(=O*MQr(sw_nk*:fقǭvS'~Ŵ~@9)ޥx7}|{gy'+t<J{cb363@NB]y,蠅]lφTP7,@DAna@ ƚj_E|^Kb4k .P=r"R@ASPUAC%9VeHU1'8A$eDx\ݪwŬ'W82\6>^6#fvŔ*kF+K)?"hluBGKxp&zxٹ9T\e:_q\Klm흍Ox,U"ћLG?Fq8~]>Bw֝3^NY¼^{Ydxtn̽?"(;OۭB2xzzz~<M%7]GpJ7l6_OС>ͮ&orte6aDL'@Чyˮhyuvzp`H܉teq\D"[j}B5Q֩VnXS=dU678LXK]Lfe`nTSPk۬Q@$Ks6u	"g[[6U]xO+*a}hC54nE<հn8ZJ
O2 0
Z,@E} WZ 36t:v	;g8l!S1sW u칣Z\lB 	2DܯQ,R8{Lі.^xdحwV	ocq_ȋKO\";0PvRZ1G
jp@Tv
3b!LTDQn	O$KS뒔}wt@!脿ꄿg\w[xz^u&YpHn_kd`Ǐȧ8"7x8iu-8w"Lt ) =T |y6}!̇ߢ3Bw]JtPv?.`(k_mC|K+L> |v`OW+_e |4`m˓}V*-!EI%*R1$%	\<(WSfR+)mp22eiTF,ǆ[x!r	iLQd慀H\F>Ed؅.dKHrǯ<'E6|KVI 	T$`ڢo&=z2ɚnMJUUJ#EdR&v
lper"~gKsr<I06XaG$й kcExHDI0#D[[|Vl2	f $kn9)UeW.s\ޕ&xVo~߿b=Ȯb;i~m6NgCDjw,1%H]!)Y@97ye}L6VzΗ+Yze+lk*tU-:fX#	
~E
fQzWװV~7vD;DSx5e%g ~֚X,'{Ff?'kS]e"H+d0d:<{2P%]>g-?g>	XJcK輪б籁-Wʓ,;0Ht9dEXd9ĐÕ^*WII%B@ȅyA[tZ
Surb;MB6	mkU_:56lf(f
	v`|Al4QZzcrP=A-3_jt kҲߒ!KԲc3nKJptFZڈ$:+/e]o(T2c|JYMR=%w=xj엮*w>$bNώ&D].Pvt N:T^* J!%P|`6M٧2rGߊN@8*%ʈ}Pgp4tHRՇŕZ5x`AJ(#P,o ZY>7ZpAQ)(6M-2!F6uo?eYJcZrUB^dj<5b_K·''rR,IK ܸpEYRq.f?k߻a2ίn>LɈե׃`O`a2(NWzݍnB'S;W'[7rȻ4g
rQㅷ{{-P؏|a;zuK|$YA}ܒэԒD˕8!&i{XaIC|~zTh=U/jh[ڇԤ.Ї{($]0C1- w['i~7ǿNQ,EA<fzq6a,:"	jfrIέ5Ԗ4-yJ1V-!L=)M]d&)cV̢hƞhxb";j(gttz'g$tن45:#?Y$s7	^M:-c,&ھ>^F>!Z/wc/D<ɋmJc*5%a:%Y(!1VNU5͋Sf}jzaqSDӍ.{E>*z8E*PiG@/bn1$"C>eG8Fa;LZHj)4u5[2r>!>woPU@5๚W1qDҦje?9+8S	|<2?IkX
V=Uͭhe6N`+1M8tcx;E4Ë=1P~X5))I9:-@ڬA=t-H%e_-G࿨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IxTn0L1ev0!يt):ĳL["d	%OdQH}?2c,Ӣe]c])X_Q	ÈڊmU,RTrhb$cf sʕ=r jd(llL!T#i;2o/y2~/
CۜTV	{w*a54kӌix4^Or/AVtwu6q5Jzb2-j!5R*Q%q;?Ԡ+&ʵ"]+M@_dV=?70.jͯ6q*Żb׼(F8mdW8)-!+^GIiu{WL.7ixMZ\G5ƚ/`//%%5{fA9xRO0W#$^S6B7KkFݚ $ο^82OMxB4@f@<Wk`2D`$ADxDaKY2M7`_r16 Aɓ*%:M
نHui\vh*rP>J);:fУ1)tԚ-W|9zTKkm~[p]it&Nr,1L']JL<t@Q<	iί%y/O&{?ʟiPU*[Nu7a0l7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 eFqv&5ȧ"UxX_o6ϧE-m}rknm@ӧiHKRğ`ÀaC>A?(C`Iwx$+Bj0A&Ǐ9} d1xr<'\rMYR8\ӵ\Y͠&K6TQuyQ6mҧؼ1Mj7z,W#72BB9ms,#\Ҡ4pnx
Q<_)NN@ˍ}B%fj%pz]9WǸ$EAxꃡ_:د`~0TTI&&?;Hr$+`C,(Gw-B!XFAʌ/R_D3cV{Y^Wya$#;c
/lE4tA89d8|E_DC&y.H9WM<+yl= a"8 |F}
eu/Io@H@,e0߀0,R\=1Rɖ
]JZ;3>WDJ\!281*~;hOb5/S;bP#W4K0P=%{ vNۅoF83>@Ǎ[)ڞpZG	QgOס-mx{t`.RW>8g86էEay{r[:4=TN.{~uj{h^^.BQۥ/$NNNDY7%Fow); ֋~cߍ]enT#n[L
.N_2%V<7Է{-NgPw޾#M5sv`f" -=z\=+{*<:>,+jo0#QHS=x:ѯ2uJiލGyQcxvӓۮailM5@85akH[*iD?4(4+p/rUaLƸ%Ϛ=;8߿_[=ncL.eN(FltKKe<	Ĵ;I)p\?wyōcv2l)f܍.J5T  q985	33ͦmn3\'4д*pF415lQ7ճ=l-aa3_tIU:ۧ(bq{kAŞV5P9px?O^:v`8_mF{^`l՞<=`^۔7v["/]b5Ғ!VgMոnA=ev S\s{=m9am0b**u&+TE>z#PQ/d_}"*UΩ#pu
TF;?Xh7#)N7]t40#?)͸1*]g>Y|ȕ~ s#|&p.r2gpiToG,9^1:8_>n
+{1$RV4mi˒@5OZ%WԾqrRtQ~ՅGrp7S>b/:bo$֮Bzԁ8	#A?eBjc7<jBk7g;@Gxo 100644 index.ts vQfzZx.v100644 migrate.ts R7Aq2JfJP100644 schema.ts &[xĿB?|7+xMO;0&qc&Xi)i!v:!54JZL
BrJ$*ТdG.2}r|Eðȕ(SHAP)w1@c
`ݘcM9R1#Q۵ê4ĦO9=킿ޝtT%%B)nxmKn0D>wBA	i{`mJ#ѭwlPt%!!&!f62=+΍wb\%J(|=.Cٺ|تE{f݅<_<~ex֑[ObXݶv*?1GrOt#p2j EZ٠ouxkd#^rwI)tv]Rh,{@V(O9`H+ESI|\jйy(}XCxn0E
(JwMF/d-PH&W@bIV%HW/y̐3HY = 	]Qȑ(B+Ybs 5=k%$.PS&1
>Wqnxv>^HhY,Кʎ./Uk=2fU-k<nc+EF(UѲn29m&JY̍jr)C}-Qg,|0}BbV$7k 
_fEGqϜ*[Kyo\F~5L&ګZnw$56iMi)4)Zi&¨G?߾Eչ+zRܘn'}<N{869mכOpI=wqYxTao0_J 	QI(hcuHN&BN)sZ}#wޝ)+%*z圕6	Z^P	<OWd4'k!d2f@HHNzbB2.㬏\׉e*&6Ke֔J2tkBpTS	!)%4caxINҧ܀F~q"25/8xGg+^®h\S00o"zj|j(n/,>FJ":*ZJ*XQm{mZl'hC3	=?a˪˴R)SVP wN}FgpθmM<[ZH-C8⫦q.}m7r;G6Mw5:ݵlf9S+jO4g[j~*NܷVR.`Loi︦cQO82Q#OuRJi}k΄NNFLU{I$మ!4g5ìfAp<x:nmq|Kf5soԔmwHf)hIR!	zm'6{U@{5_I7b{h[x}M0A${!ԮI~D	{+c#$];H7fy=_UTJ
%+|\i2_qrƏȤ(XA}Wa+
y{șV1z.i~jÅ%x46}Rus΅k.i>ϥ凨0gMGSw݈.ڸQ)r!Vr!/]z+b	Z>H!]
Z5"Y͑7ӀS/ҮXM-eo{0fpex,]XFL23\кc[{ ȃa><(>XTó;T4Scry\"2jV
6r";&Y?Ս2ZwbDjN<$;[DKM0tϱ6opLT:掽w}sdUs0Z/3@|N?pi2[	 -40*w+C/jmMf-	x k100644 files.ts 5z\AUhP)=*7100644 health.ts 
F97MZC100644 swagger.ts 읩.{S  Kc100644 upload.ts ah_
:=xV
import { eq } from 'drizzle-orm';
import { db, files as fileSchema } from '../db';
import logger from '../utils/logger';
import { checkRateLimit } from '../utils/rateLimit';

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  return new Response(html, {
    status: 200,
    headers: {
      'content-type': 'text/html; charset=utf-8',
      'access-control-allow-origin': '*',
      'x-content-type-options': 'nosniff',
    },
  });
};
S{ZRxXo6_$ҮX-{ڄh$:$M)ivKw-Vt%Z9g
2N?}>E0١%ү>C69IpN%)q6RV§ǰhHy)uOd$XWKJʪkjZpcZw7+% BЯvy56l ˣW}_ac'q #!_,8!J<Z2*g8CSM%5D7V䗷ZyOUhQh!e%^^`X zϟQ tBO:e sAW1[gX ̶65/ǖW;$&qH^;UNS,Wܸ'_k/S,F	7'(8Y3 !)8/bC\	,*AO2l@"]lΦX$zF'.HUAzvM2#Ͼ}ڲ5d{!0*bh\1pu;c{`5ڀ\͑,RRc:0݋]%A=Dp0+=]qf$ڧĝKUՆR97|Nx?zC,RؗPH+a!2sY!֗A8tkZ	4T}w֯\zX>zoN*GwZւ8'B,StPȗ:Is|o̕zZ39͒Xe#ikt/`eX]RwK*_GyĢ/kρ8$d*FcME>u$l8hv8esdv JZ[Z9YW	${vEP[̰* xJΰP,UPȕFm]җ	ػ`+ኜ|=nw}a'wMŌ+dWI,v`[xQxURA>x UJlfASv1<mʠ]_xc]Rmnz7*e#-B8yS )!Yn5R]V/XZ-M]|<wbRf_T, ^es0'Ϸ{L0@+DPo  k^G|imi1Op~sPo@&!BmRG
 <7o<&fk{[abmcPSZ=#1p'	StuޢE:v]DVw:=<u}*gjcZ]D*WFO6Ib܁pc#6V^m5=D3d!A`'uWֵKWb0nq^!E+>Vi6vJ}l!m2㷘f{On$5)#o5S'a3.+˻͡5<^wGtzTBѠ踖-_7W@B!??s,8DȆ"/dJR uMe\z@1C~II
pAg&Fm(F{`bYm"=r$j{ɏy} A%$oF ䷃v>.:7K膉ژMnP>CAfƺf1Ӻc;úNջznG0ˡMܘ=nx4 EB(;fileType!%fileType!p	x340031QHI+)f0bv?LK+-y7(B䧧MMK:H%Lťi@%dfn,-2*燞MoHBՕ椦%悔',[wMhjmW 7xVQo6~ϯ`[vt:mf+a0VN6Y$jH%˵=QxH?3N>|!$d<C|5!A+ȏ?/ke!J[[*p6_(-%ԇc /+$>a׌SR'z܍`͒_ >|0@z]wE⣨C|p'Zᔈ<1Hё(''HzΨSMnM8iXDБxL,$u'3l5~ٖbrӘiųimHGF	DXC`Yg? ֭`KIS?낆Z-2$lD6q$̪J(K@14_9ikuQ&{ z!!jI[ +G5f^e%wuH*rcf\j	,Of^ջ0ZB Cp+zb6{Fc3i[MbӚNǂuK}⅔R?0PEXÚXX(k7ʄV,BE	EWmAs/MѝSOMq':
/;Q3ҪzNlvt`H*4wE*mmaÿ8V(zaz٤HcÙYu*4sK^fv3ȑR8,a ^%.5hy蔚A}}ܗψ}-O]ب^&>&A~tDj!lҺ>*is.vx+KM6;Ujѷs,{-t?9d\=P!(]@MܙH1z(^HJ--1rO\Sh#$	y.jk]%/VW[B 9mUXYq]Na lY%ǘm*fTV٦]ƶXὗ-{'R>cxBdM7'_KogK㠼;3CC%" 6b41xSMO0WSZiv)H*I7d 6$Tg#`dqԂ$V`Ǖʡ8O=0FSAH%u9Zg>>@hې1!PRQ-挀[A]^N9r>
gO6/8567ѭ
D@}gH>NWcw: Hc<hL_k6m0X:",/9Gml VIG1OX8nudx̆Z^Mv<EQC@}Ϸwvg)jQ@rF2I):-qqUE~ýA ^?Ql_3®I	h	xq ,,
  ],jConsole({
    p!?winston.format.colorize(), winston.format.simple()),
    }),
   )#Fxmimport logger from './logger';

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

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

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

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

  const record = rateLimitMap.get(key)!;

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

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

  record.count++;
  return true;
};

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

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

  for (const key of keysToDelete) {
    rateLimitMap.delete(key);
  }
};
ܥlzx{˱qlyd'+2+(d'%*%*df(d%fde'qZs xXRF):UJNC¦H-B([X#{q4c!ϐ++YHKWqx{Z-trQ$9K;P4eYBgN%{${(l6#WwЎcGC=ɺGFutbq&s!EKpiY65OEo>R1KIcB< G=J8j~W$Ro^'HUAQI#v@f'y>Dٺk5u~-rSy4#1fTyF9pơ:{J*JQcG՛7.c8`gN3.Lh 70<'z)|w0b.1MW1d*H"S26&\aa8	$ Ú30l2px M	[	{|ΕklphWsq2w
.aVI4%HM<$|OGo\}શG5ǌ"ir.XqBʲUzG0nQ9&e韤4HX/I5\Fb^{Ǻ7Ԉ]kDlU}(lTt*jfPh`Q 6*C Pf* .ނ,`b`03rh귲G!SN NJaf
x6ET4Z
ʲs,8i@bSijhhLvA=N.huBFq\.`}j.cG\x?*%p|E/|YS:n`d7cN-;.SkCW#UîT	OBmؽ9mu%lSëN	[ɋO
[niMYg8gLw7ݚY(>}O6A0Fxؐmm|}LcdU[M$T{ZyAI4	\Z<s I0"0i'=4#-gթy5YV׺=]B{doS*{GH:ƙzqrk~~W
1Ӝ*$۞x!?`')۔IHQ-%YDʻp=r}<'9cWygWL+b\U'fw,^C]{}ݘpQt%VP;k9IX@8՚JRuW4]ԏ">^n@:BKx340031QH/).)J,+I-.+)f82U˩ܒ&^.nS\WOfJޞЖ]D,%	*ayn7'ߚ-b=3Y)TUj^\Y8^_ͻߴjn̜Tŧ<{JC}xH#b´",RInĜ;#_]*GtʢĒTLox{|\z9g]_C'ܯ:R22ҒԜ\ZS
ޟt.wQ>3*҂J<}kUX'X3%a ˔-bxTۊ0}W̛p>'l&le>RdyHBtCȿWizم8u4gHs\ޚ7BSJj' E}y6RBj4T
sg	hrbmt$T
c#p,2 iC `"q]Ky'{0cZw䫰r$(@j჈	&^C<Wn[iGA/wNׅ ${Y;YZѴMs|M_j(VDѝA^DFdROq4ט>zk;O/"ᶽV."C,ggI9%0Y8z$\	i)BuYGK4&[P'r1DfԴQmRQQ40D~fsxLvewy6*#"ξ^̀G37`<z|{5iCoG4^r%Ql߫r\/{-ތ`#_;rLxYmS8_ovn(R3P(\)d[ITlg@Ջm9v^s2e,kW>+3ߤxx8#m&)$zDl/{hL&<#8PDD1!P߄=$d<A8g$Bz;̘O$~ pK gg0@@"f%Nчrt9|yxvQzO?,?qr_q..?\㽳ZVO6766]O-Ibǂ31}IkK`fx	9R,t]+z楂3"x|GnwYqXRڦq;jOx!ByZ(v!-?C3_[º%	!9gagܩb
KaK<8pY~^N_cYtDX181	_9}5/҄ʏ{dEX
*BXwێ Z tE䡌'<686j9p^R&4V6m&G0y_99Tq2Ṛ\F4nkH8..>?Z"L=TJ#`52V[޺`+H9PGbkM2.6`A[P6Ko|yCg|;E=P%:PP9S+DNVlh'Y<dv	v:ҹ	
Ӳћᛄ`=1y*)4j4Ŝ5q{=I8cH[(LI
E0Q}<]	9PFHP;O;qLTdJWt.}JRκMaCB^\5( Kg\r5Gp=hnEH5KFK6)aS_h![PWCkez"iINkjQ(hOdܲ;r·gqyslDys	_@'r*^uj\nILNNAaV/^A*lzԲZ&) whsck%%4!#9O4iS &M'/ɋbXLUu\׌ݶ*Fٔk\>DꠠKfXE]FO6F(HiN:k'] Ԭh0ŭƣ}[VMY(S2ximEr:
X[Y @LD)J(7It[񼀒bS G*.U%q;gC}4|N~esӹp=7u~LpJpK~n-^{*#HU(Z%Raw
SrcSH}nm'Z0RqG(Al
7o:?@_U੦n~&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~NjhxTn@`$j"ШU	E**;U̎&{װ&`lyޛy|敢x]K3@v2;4!ѴEc6&${F=TW*l6RZkɣJVb]:=<kVV|sQ@"1 ^zvBKES>,";;Ts٤X|cqtڰy!7,U$ЍauJ	n-NZR	1níۥK>߃݇hVp5/ϣ.r	#n+*p񄐃 j"kHeqz|v;4Zhf3HHuvtus\{,E߇ۆ""MU}V+mMHC[;|?ZԲ_鐔RCD1ޖ*i.M#G'M){^ƶIWVQi\(? x<"Nexpect, it } from 'bun:test';
import { config } from '../src/env';

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

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

  it('}<'\	#'<'\*{+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');
    });
  });
});
[xXmo6_o[Rh]dX[.|0tHJRI}Go͚Ȱ~x%'?j5"CtLHM	jGI'!LS$IB"'?nȒLH|Aiޞ_,TI^$v$BĽ#k6J$l7a
<	J$g׷/4LR	JF~Zq8"ŚZ?jĂ)K@UzY&,:kBSN4)^d3(q 0ƥg$,Q-uS9ͅʷC19.=v9Z@tbT6~涷[ݫ>@yl{Y	2׀]h:eUJ5)5	h2y	1>/-
;क}.UHx50*]4,ncWhr	RD_\O/~9=G;//_ǃwNSDfDCB(F(6u@SNi躿pvWwncdo-ϬM(a;$X\GED%'7M-ARD*mY%T3/x%XLW	p{%,hd.AO}S,vje3R!}Y&Sef=UpM.BE!)KzcpU+EQG%
d<Rȹl]h!T		N0^keH]jeku/ >oi/[W:}%Sp9di<5]'uw36Z4&I<!KG@֩"'~ma,`Ew4#M])6U*s윦PȺruVr΄`Ig4Æ)~9rR;ۣxBlC_?Vr}D4-IȤixR8`9c9BbO+ #95}8M&rMMt7&\ϸZ41'WqX6ٝZ=t^M&e)\VwŊ67ugfkN%ThcjD<GCCpa0|5^^}{șߝx5G1)Sel Ncw]ݤݬh[[kfN(b;a'ωP=;vWU6PxS͎0)fGJj/*(=T{P86B{PEK3|W|uҼEͺ~34WtrW@^-\`ctT:9˪
~`5"HvBq_,HSzk1H,+kx%S@45_#{=*|RdHz52Zx͚)!hDm<O50u0ɇ2{R|r/%2Cv(ȗs¿jBVДRhim),|8-*Ύ'u{ ϸ)iU%5g՟fٝ+5+'LVRoWF_VטG$=@Ą&ȚQ-(2}3EcǱN[)W60GأVww%+YaJR+O={)$V{|BbA[,\'qex Ĉؾ1xRAN0s#RVH8ζvUտcIK9Xqv3c%)-$vIv8׎
T>;.G
GnVud]:lȌpQֈweD.N}pGOM$\Y>u%f&KZJV!lȑ iNcT}9X	.ELEn{7Ǡ
KV[zG-EX^I܁8ʎion	WîdeKb;\V(<ВH|BT3݅[ɏFX)l2|=\|TWxTo0_q<9Z7`؇M@'{@!79ivE>YOCs&X
Ĵ6o	w=%^ߩ[<Hg,j6ټА"pV
I
GǰGKG	0vh>\]@zwy=&"@)nr/urm'0XST=pȐ'}spBÀEi\Q$B9WnRf&:6K٢m)^@oKPNo7r("i34A'X+m<XNѢ{~g3ODdnIC1CXl)O.u,[;IgI?j'9=F@TuI)OjP(R^!u{w۩M>}7[n=ey)
F`1;b_O'_xy~	ٽ\q";ɬǓ/a{ʻxXnD8?U$MliыD@(4^',< #p7I)HΜ9s.߹b_K0aBy!H,i.hjGTs4s`Yr"߼ʠk.l)ޓiȳj X"\!JjE.A)Y*f)wx7)M 1,0Qk)=i938
nv 
*"_氄0%BUP
Y/%fL*gq9 hNKm0_xZ3A
^QB=g<r2c&¯)F2cJ_P/UPڿ;?\L~#>>xח'oގ
yCP׋SP9U;1\f7?	9xjC:CFtzGSo^$NlTkRD-yѹÊo&'(@r̥@+DlFӔ+8;_4y5l2@HF9?}2gي0PtiQ0m2f!I<O
!)cvJF	Civ~79R7J@mnA1Dk&F"
!I;oiduЃ^FE4riZ&Wd!tВ`k3LYF1:yRVQeVVRDudU\%A=F8Lu>.*(HՊ3KVT>פ|u	1BT5-ڲ%I[I[p$z2R^zk&ݟKfwM1J=4{QML|O]AݸhS[^fYj#dF	0EHMd9٤D[2]~+~aGWcԎ͠L8vi:뭧g'Qk%Q]6ZVml	GVkغ	o*iwA0A!uq;];U[s8Ti~*s%DP'4CKily<84)uq҇ wNSAV1XS){=B~T:-5Vb-ϣ#؛u~[Q--5t{lWL2+Y=Q=[]E-jM_MI2igD @}V&*w&x؈Я;Q鸙
,S̞
vwwA1;zwbSm[:[=gVe}qDieeLW*n¤=+F5ՙ;S_oxwy#|Mϑ{hVZacF[/|r#ֈFZ,bvuxOg~Y8.1dpVd^Nkřb{LXw:I6rl!}c!6xԩ1\_ ]t77kycf
wnmD{9|Q8xYoF޿ّ;-PiiM$1>sw&P=ϝc-0ƹ{_~]gFKRH>2> eِMa&$Ő%bNa༄XY\I3oZ{{aȞ^2@cȐ*){ޜ"u,
eHT2==Dᩂ@e.@B[qGý6c˷$yFtϚIA֢k>i@1~Y*,A#\$UqLôH2Gl23͑cЬ+JEgfdǋFo"MfصvKC2#"ޒQ\U:͔'!\&'X\PYqяh#	Uny"}_i+45(oDy;&fvMRg-Yv4loxl43*qKs4(|[sްqV9Б˼fSv_4v*h^	{%*	/J/nIժ&~@Gpfs-:;mEeZ@QlU&06_rX2BKՂݰ"#&pԙ(Q-D%L[ŪPUI !k4Q*L&%k%{؅
,.0D̳P:2m\.K/_O@Mm2>uXf<-lT$+$1Y5hlW븻ѱ:v
l81>7v-*Qm!DB#t
mNڑ	nK)b6u?
obڜUY32KcSQ]#02-lo$LMd@,l)d=6lgC-<SBJ\o|9ntj%4T)!nM+9DmhOЮ"hW9ɮ!-x)<RZziG41]b9ƴ䲙E0<B ש}\[>P'DiFopgq͙3ZQ"=ҁv66YgFp_m5k8Y`I+ʶtK*jzR4}g:۾ OF&s{tn5.^-&bT}Ng׹޽w;5&-lwN8z3Yiy|kLl$Xf-yj,ap43ڿw2G7U܄V'\'M	SQO^ͺ]ÕeA<d,!eP1A
 I|?8YIS(pKxB_!;_rh`{L$SF_Nc 1Hͧ1WZKwan
iۀL_Q?TԲ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%x340031QKON-L/Je`<;5S/S˫BV$2<PL(4COL71 ̒$ߟm?Xh;L0UkoL*ڧ^swoMSSrE/U⪗P#{s̽f)j^YSL&)=2:٧v)}<PUA.`f	%n;wf/B%ee1OrIxݹ!}T -etDy~)wvC@BT79? 8U27jK6~0^@ٯNRb7g{Қ	P
ӡ;t/v^'N*_r89#57Q01kډo5tgXV\ mޚ3Y^g}̂Ȗ0uT(:sܧ\߿C()NKLe1w%&svF _8x.vWxɳY47* <է	x h100644 file.ts 12NFR)9 100644 logger.ts fc{_5>r(100644 rateLimit.ts ܴĒgh"100644 telegram.ts :ib2Ue**͔'d-t!8~xT #޳<5__ c_>?_#ct!;$	l	B
Tx \@:#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~(щ>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'+_Zx 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x? 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~윑 :l|x۫Ysl| % x JZ/Rk#G/_ CV<	Fx 3?b9o)ҕpJx|Mx½6ʌ"Eɩ.ɥy%

i9ũX-J2@b(j>̟ܨRꗘZ` XO⡟W\PZ\SbWn+$'fLᴗTX	ʚڸ#Z!V'TR`"V9d7o~
 אX)Wx 99$0
e._CxG 0(k#d%false+true);

false^ۓ2$'\Vx ((kd*Dk(xreXf練&"Ýe"*zcW_xGGOQ>|Fǯ2Kb>pBqrFjn^qaC
|@û/?U(O)z(%3H~sǩ/',j="'r"[Z\°}E=pb{ףϻd1" ڗSFxX 8<O dU&42?X3?*X~"$'}G40000 test ,0hhN)WT~L`K+)"jlx
 Ck&xyc·6>
Ņ9
*.!NA>
i
z@ͳ0 `=x" Bbun src/db/migrate.tsb17x91U!u4GSa~.'`r,l59Y QV+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cxo _=q1W6#.ڑs]]4erroPhGD3HYth5i<ϡk40000 test z"	Uʯ_&pf+)1N2P~x? 31XFNb"`40000 test <M	3E:Î+)yx ~+ӇΚHEH溂xt EEK checkFileSize, computeHash, ensureExtension, extractMimeType, getFileType ;251hгo
251hX	!*@Wx 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8x۫E~R*n!
)ɥy%
5
%`VYfJ*X	LNe&d+*$Tp) ͫX,j&aly6Ӽ }*Cyx NŎP!CBJ97
t.x nV% c1Ȭm 
}Ix y:!2'8605908810:AAFpUzlIBktfd_7wpEj7zMJob2CFxvG-ZGY';
= '-1003996572954MyJ},
  files: { R	!ȳA/y'^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'/x ʅzww?cdx' E2"<V
`R
8I`!"{@'S"xmvXjB2Y5Z'bvTR TTjLZ&3r*(j 5'{X#ĥ&OgV02sR3Rt`*N3BA@sr;OX8' Ī1Y}x? 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)ҿ};kx kƳh||`qR3xi )!H> checkFileSize, extractFileName, extractMimeType, getFileType Xn	zO
});
P$|x 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)
\k}x1c1 g #Gx  C,$t*4ѓ*kPx 	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Xx &"O#^7zMx;ZoRBrFjr[fNjpfUBjEIQbrofnjHeP =$(lʘ;+r\)ɡܒ&^2ىG&mmc<J~f&OIXjfiͥ y%
hM.?T ȓHƵN^'3i	97̓][B	x h100644 file.ts .V/ Ou`,C100644 logger.ts fc{_5>r(100644 rateLimit.ts ܴĒgh"100644 telegram.ts 3?b9o)p:jx됾Aqr6W2jj&'۬ɵ	 
ckx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  jjx
 Ct?x8 _!Z]6tdh8@B}suvȠI^
+)Ax  WoZ|=3IK*}x7 _!Z]6tdh8@B}s]]4erroPhGDpdC2x;mOcB	YsS'՞-yFTMk٤&o5400435745+	-d#v*Bl *9x$ 40000 workflows vppȐDYN$x& 100644 deploy.yml 56Y"ǉYq x Ct1.1SH?x !1.1-!H1.1-iCks.xP _2zߥ;ؿaB06xs]LB_x<6^3tvNu[[.+)&$x;|yBȖ]~:_=K* ʿV*x) o(schemaFiles$	O7,ixG )h40000 docs G#MnJN=\\^ZewtvNu[[.+)4x& 40000 superpowers 4inҴ~YN ʠx@ 40000 plans ^p@mU+Q40000 specs I*9 йtBdx }HH8ex JJx# QstvNu[[.+)9I#x 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+x  F։E뽻*5HHx (![E*/x  YN1x͇*J5;dmH*x ar~7}; ݊[x \ 

J=҈--9QG/o%
4ExI GF"	tele.asepharyana.tech/f/xYz123!
%},
  });
};
/xX 	)vwh|tq=V)03T@+iɨ΁xN40000 test 'Qi?u=N6)(Olex 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hx nPv	k:x P{OI#x `}]"٣|zx ̯ %
T`7
Xx   "7_Xrgd)L='x !src/index.t+EVE\xV j}p}V	e&13=<MgN-40000 test {>R*ʄ
e)M$gx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+7x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mx[ import { pgTable, text, bigind1*import type { InferSelectModel, InferInser*n{ ,x( 72K n! + '...' } });

ix\ 	$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zx S/+/.".select().from(fileSchema)*0˳N'Location': redirectUrl
      }.select().from(fileSchema)*0{
&C e " $created_at:	# ? file.createdAtT	)
    }, { status: 2005	xJGx ?&##en88	+x j../*1	!5import { getFileType,{ Y$co@B
}<  ? 'document'ۓ@wyB
}몃4l	x h100644 file.ts (gB"Q&cc!100644 logger.ts gT`x]ɲU:SE100644 rateLimit.ts ܴĒgh"100644 telegram.ts i3ō,W,jP`i/9>
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%] x Bdescribe, it, expect, mock, beforeEach, afterAll } from "bun:test"Zxc@ Promise.resolve({
  stop: mock()
}));

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

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

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

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

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

describe("Bootstrap Server""-"!"../src/index""port""routes"3"/api/upload"3"/f/:public_id"3""3"/health");
  });
});
*=7x-! 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35y7x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]cx(-,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Ax+
	8describe, it, expect, mock, beforeEach } from "bun:test"PZ"../src/db/index"+F
  }
}));

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

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

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

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

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

  it("
," 	F)"multipart hello")], { type: "text/plain"%"file", fileBlob, "test_multi.txt");
/	"a /", {
      method: "POST",
      body: formData@OzI"mocked-nanoid-id"#"test_multi.txt";^r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+yxW 1$kjj!jjjj/j/otstrap.test.js#
  }
}
Ox4 рTa\;=WzOj;l83_s2#x1 1$?types/bun": "latest 
  }
}
kAx  xJ @QLߛR!׳8vTIA6
Bv _#= IB(k,U&#ox   6@});
3f}x_ ++%OspyOn, beforeEach } from "bun:test";
import logger from "../src/utils/logger.jsJV});
tl!0x 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>P;4{iq(it4