Files
TeleUploader/docs/superpowers/plans/2026-05-17-telegram-uploader-backend-plan.md
T

31 KiB

Telegram Bot Uploader Backend 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: Production-ready backend for Telegram file uploader with dual upload methods (bot + HTTP API), PostgreSQL storage, and redirect-based downloads.

Architecture: Bun.serve HTTP server + Telegraf bot + Drizzle ORM, running simultaneously with in-memory rate limiting and graceful shutdown.

Tech Stack: Bun runtime, Telegraf, PostgreSQL, Drizzle ORM, Winston, nanoid, ESM JavaScript


File Structure

src/
  index.js           — entry point, start server & bot, graceful shutdown
  bot.js             — Telegraf bot setup, file handlers, forward to channel
  env.js             — validate bot_token, storage_channel_id, base_url, database_url, port
  db/
    index.js         — Drizzle setup, connection pool
    schema.js        — Drizzle schema definition for files table
  routes/
    files.js         — GET /f/:public_id, GET /file/:public_id/info
    upload.js        — POST /api/upload (multipart & base64)
    health.js        — GET /health
  utils/
    file.js          — file type validation, size checks against Telegram limits
    telegram.js      — forward to channel, getFile API calls
    logger.js        — Winston logger setup
    rateLimit.js     — in-memory rate limiter (30 req/min per IP)
package.json
.env.example
schema.sql
  README.md          — setup instructions, deploy guide

Task 1: Setup Dependencies and Configuration

Files:

  • Create: package.json

  • Create: .env.example

  • Create: schema.sql

  • Step 1: Create package.json

{
  "name": "teleuploader",
  "version": "1.0.0",
  "description": "Telegram file uploader backend",
  "type": "module",
  "scripts": {
    "dev": "bun --hot index.js",
    "start": "NODE_ENV=production bun index.js",
    "db:migrate": "psql $DATABASE_URL -f schema.sql"
  },
  "dependencies": {
    "drizzle-orm": "^0.29.0",
    "telegraf": "^4.15.0",
    "winston": "^3.11.0",
    "nanoid": "^5.0.4",
    "@prisma/engines": "npm:prisma@5.8.0-standalone"
  },
  "devDependencies": {
    "@types/bun": "latest"
  }
}
  • Step 2: Create .env.example
BOT_TOKEN=isi_token_bot_telegram
STORAGE_CHANNEL_ID=-1001234567890
BASE_URL=https://tele.asepharyana.tech
DATABASE_URL=postgresql://user:password@localhost:5432/telegram_uploader
PORT=3000
NODE_ENV=production
LOG_LEVEL=info
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=30
  • Step 3: Create schema.sql
CREATE TABLE files (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  public_id VARCHAR(21) UNIQUE NOT NULL,
  telegram_file_id VARCHAR NOT NULL,
  telegram_file_unique_id VARCHAR NOT NULL,
  storage_chat_id BIGINT NOT NULL,
  storage_message_id BIGINT NOT NULL,
  file_name VARCHAR NOT NULL,
  mime_type VARCHAR NOT NULL,
  size_bytes BIGINT NOT NULL,
  file_type VARCHAR NOT NULL,
  uploader_id BIGINT NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_files_public_id ON files(public_id);
CREATE INDEX idx_files_telegram_file_id ON files(telegram_file_id);
CREATE INDEX idx_files_uploader_id ON files(uploader_id);
CREATE INDEX idx_files_created_at ON files(created_at DESC);
  • Step 4: Create README.md
# Telegram Bot Uploader Backend

Backend production-ready untuk upload file ke Telegram yang tersimpan di private channel.

## Setup

1. Install PostgreSQL database
2. Buat database: `createdb telegram_uploader`
3. Setup environment: `cp .env.example .env`
4. Edit `.env` dengan nilai yang sesuai
5. Create table: `bun run db:migrate`
6. Install dependencies: `bun install`

## Telegram Private Channel Setup

1. Buat private channel Telegram
2. Tambah bot sebagai admin di channel
3. Dapatkan `STORAGE_CHANNEL_ID` (misalnya -1001234567890)

## Running

```bash
bun run dev      # Development mode
bun run start    # Production mode

API Endpoints

  • POST /api/upload - Upload file
  • GET /f/:public_id - Download redirect
  • GET /file/:public_id/info - File metadata
  • GET /health - Health check

FAQ

URL permanen maksudnya apa? URL backend tetap permanen: https://tele.asepharyana.tech/f/{public_id} Ini berarti URL service Anda fix, bukan jaminan file Telegram abadi.

Testing

Gunakan bot Telegram untuk upload, atau upload API langsung via HTTP.


- [ ] **Step 5: Commit**

```bash
git add package.json .env.example schema.sql README.md
git commit -m "chore: add dependencies and configuration"

Task 2: Setup Winston Logger

Files:

  • Create: src/utils/logger.js

  • Step 1: Create logger.js

import winston from 'winston';

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { service: 'teleuploader' },
  transports: [
    // Write all logs including error logs to file
    new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
    new winston.transports.File({ filename: 'logs/combined.log' })
  ]
});

// If not production, also log to console
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.combine(
      winston.format.colorize(),
      winston.format.simple()
    )
  }));
}

export default logger;
  • Step 2: Create logs directory
mkdir -p logs
  • Step 3: Commit
git add src/utils/logger.js logs
git commit -m "feat: add Winston logger setup"

Task 3: Setup Environment Variables Validation

Files:

  • Create: src/env.js

  • Step 1: Create env.js

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) + '...' } });
  • Step 2: Commit
git add src/env.js
git commit -m "feat: add environment variables validation"

Task 4: Setup Drizzle ORM Database Layer

Files:

  • Create: src/db/index.js

  • Create: src/db/schema.js

  • Step 1: Install Drizzle dependencies

bun install drizzle-orm @prisma/engines
bun install -D drizzle-kit
  • Step 2: Create schema.js
import { sqliteTable, text, bigint, timestamp } from 'drizzle-orm/pg-core';

export const files = sqliteTable('files', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  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()
});

export type File = typeof files.$inferSelect;
export type NewFile = typeof files.$inferInsert;
  • Step 3: Create index.js
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;
  • Step 4: Commit
git add src/db/index.js src/db/schema.js
git commit -m "feat: add Drizzle ORM database layer"

Task 5: Setup Rate Limiter

Files:

  • Create: src/utils/rateLimit.js

  • Step 1: Create rateLimit.js

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));
};
  • Step 2: Commit
git add src/utils/rateLimit.js
git commit -m "feat: add in-memory rate limiter"

Task 6: Setup File Utility Functions

Files:

  • Create: src/utils/file.js

  • Step 1: Create file.js

import logger from './logger.js';

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';
};
  • Step 2: Commit
git add src/utils/file.js
git commit -m "feat: add file validation utilities"

Task 7: Setup Telegram API Utilities

Files:

  • Create: src/utils/telegram.js

  • Step 1: Create telegram.js

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;
  • Step 2: Commit
git add src/utils/telegram.js
git commit -m "feat: add Telegram API utilities"

Task 8: Setup Upload API Handler

Files:

  • Create: src/routes/upload.js

  • Step 1: Create upload.js

import { Request, Response } from 'bun';
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, res) => {
  try {
    const contentType = req.headers.get('content-type') || '';

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

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

const handleMultipartUpload = async (req, res) => {
  try {
    const formData = await req.formData();
    const file = formData.get('file');
    const fileName = formData.get('fileName') || extractFileName({}, null);

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

    const fileBytes = await file.arrayBuffer();
    const fileBuffer = Buffer.from(fileBytes);
    const fileType = getFileType(null, fileName);

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

    const uploadTelegram = async (forceDocument = false) => {
      const result = await forwardToStorage(fileBuffer, fileName, forceDocument || fileName.endsWith('.pdf') || fileName.endsWith('.txt'));
      const bot = getBot();
      const fileInfo = await bot.api.getFile(result.telegramFileId);

      return {
        public_id: nanoid(),
        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: fileInfo.mime_type || 'application/octet-stream',
        size_bytes: fileInfo.file_size,
        file_type: fileType,
        uploader_id: 0,
        created_at: new Date().toISOString(),
        download_url: `${config.baseUrl}/f/${nanoid()}`
      };
    };

    const uploaded = await uploadTelegram(fileName.endsWith('.pdf') || fileName.endsWith('.txt') || !['photo', 'video', 'audio', 'voice', 'animation'].includes(fileType));

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

    res.status(200).json(uploaded);
  } catch (error) {
    logger.error('Multipart upload error', { error: error.message });
    res.status(500).json({ error: error.message });
  }
};

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

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

    const fileBytes = Buffer.from(file, 'base64');
    const fileType = getFileType(null, fileName);

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

    const result = await forwardToStorage(fileBytes, fileName);
    const bot = getBot();
    const fileInfo = await bot.api.getFile(result.telegramFileId);

    const uploaded = {
      public_id: nanoid(),
      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: fileInfo.mime_type || 'application/octet-stream',
      size_bytes: fileInfo.file_size,
      file_type: fileType,
      uploader_id: 0,
      created_at: new Date().toISOString(),
      download_url: `${config.baseUrl}/f/${nanoid()}`
    };

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

    res.status(200).json(uploaded);
  } catch (error) {
    logger.error('JSON upload error', { error: error.message });
    res.status(500).json({ error: error.message });
  }
};
  • Step 2: Commit
git add src/routes/upload.js
git commit -m "feat: add upload API handler (multipart & base64)"

Task 9: Setup File Routes

Files:

  • Create: src/routes/files.js

  • Step 1: Create files.js

import { Request, Response } from 'bun';
import logger from '../utils/logger.js';
import { db, files as fileSchema } from '../db/index.js';
import { checkRateLimit } from '../utils/rateLimit.js';

export const handleFileRedirect = async (req, res) => {
  try {
    const { public_id } = req.params;

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

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

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

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

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

export const handleFileInfo = async (req, res) => {
  try {
    const { public_id } = req.params;

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

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

    const file = result[0];
    res.status(200).json({
      public_id: file.public_id,
      file_name: file.file_name,
      mime_type: file.mime_type,
      size_bytes: file.size_bytes,
      file_type: file.file_type,
      uploader_id: file.uploader_id,
      created_at: file.created_at
    });
  } catch (error) {
    logger.error('File info error', { public_id, error: error.message });
    res.status(500).json({ error: 'Server error' });
  }
};
  • Step 2: Commit
git add src/routes/files.js
git commit -m "feat: add file routes (download redirect & info)"

Task 10: Setup Health Route

Files:

  • Create: src/routes/health.js

  • Step 1: Create health.js

import { Response } from 'bun';
import logger from '../utils/logger.js';
import { db } from '../db/index.js';

export const handleHealth = async (req, res) => {
  try {
    await db.execute('SELECT 1');

    res.status(200).json({ status: 'ok' });
  } catch (error) {
    logger.error('Health check failed', { error: error.message });
    res.status(500).json({ status: 'error', error: error.message });
  }
};
  • Step 2: Commit
git add src/routes/health.js
git commit -m "feat: add health check route"

Task 11: Setup Telegram Bot Handler

Files:

  • Create: src/bot.js

  • Step 1: Create bot.js

import { 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 { file_id, file_unique_id, file_size, mime_type } = ctx.message[fileType];
        const fileName = ctx.message.document?.fileName ||
                        ctx.message.photo?.slice(-1)[0]?.fileName ||
                        ctx.message.video?.fileName ||
                        ctx.message.audio?.fileName ||
                        ctx.message.voice?.fileName ||
                        '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', { public_id, 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;
  }
};
  • Step 2: Commit
git add src/bot.js
git commit -m "feat: add Telegram bot handler"

Task 12: Setup HTTP Server and Bootstrap

Files:

  • Create: src/index.js

  • Step 1: Create index.js

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');
  • Step 2: Commit
git add src/index.js
git commit -m "feat: setup HTTP server and bootstrap application"

Task 13: Update Existing Files

Files:

  • Modify: package.json

  • Modify: index.ts

  • Modify: tsconfig.json

  • Step 1: Remove TypeScript files

rm index.ts tsconfig.json
  • Step 2: Update package.json to remove TypeScript devDependency
# Edit package.json to remove lines 6-10 (devDependencies @types/bun and typescript peerDependency)
  • Step 3: Install dependencies
bun install
  • Step 4: Commit
git add -A
git commit -m "refactor: replace TypeScript with ESM JavaScript"

Task 14: Test and Verify

Commands:

  • Step 1: Run development server
bun run dev
  • Step 2: Test health endpoint
curl http://localhost:3000/health
# Expected: {"status":"ok"}
  • Step 3: Upload test file via HTTP API (multipart)
curl -X POST http://localhost:3000/api/upload \
  -F "file=@/path/to/testfile.txt" \
  -F "fileName=test.txt"
  • Step 4: Check file info endpoint
curl http://localhost:3000/file/{public_id}/info
# Expected: JSON with file metadata
  • Step 5: Download redirect
curl -I http://localhost:3000/f/{public_id}
# Expected: HTTP 302 with Location header to Telegram CDN
  • Step 6: Test bot upload on Telegram
1. Start bot: /start
2. Send test file to bot
3. Check if file is forwarded to storage channel
4. Verify response contains download link
  • Step 7: Test error handling (file too large)
curl -X POST http://localhost:3000/api/upload \
  -F "file=@/dev/null" \
  -H "Content-Length: 10000000000"
# Expected: HTTP 400 with error message
  • Step 8: Test rate limiting
# Send 31 requests within 1 minute
for i in {1..31}; do curl http://localhost:3000/f/{public_id} & done
wait
# Expected: First 30 succeed, last one returns 429
  • Step 9: Test graceful shutdown
# In terminal 1: bun run dev
# In terminal 2: curl http://localhost:3000/health && sleep 0.1 && curl http://localhost:3000/health
# Send SIGINT to server (Ctrl+C in terminal 1)
# Check if server stops cleanly, logs show shutdown sequence
  • Step 10: Review logs
# Check logs directory
ls -la logs/
# Expected: error.log and combined.log created
# View error logs: tail -f logs/error.log
# View combined logs: tail -f logs/combined.log

Task 15: Production Ready

Files:

  • Modify: .env.example

  • Modify: README.md

  • Step 1: Update .env.example with production-ready defaults

# Copy .env.example to .env and update values for production
  • Step 2: Update README.md with production deployment notes
# Update README.md with:
# - PM2 setup for production
# - Reverse proxy (Nginx) configuration
# - SSL certificate setup
# - Database backup strategy
  • Step 3: Commit production-ready changes
git add .env.example README.md
git commit -m "docs: add production deployment guide"

Summary

This plan implements a complete production-ready Telegram bot uploader backend with:

Bun runtime with ESM JavaScript Telegraf bot listening for file uploads HTTP API accepting multipart and base64 uploads PostgreSQL storage with Drizzle ORM Redirect-based downloads (minimal VPS bandwidth) In-memory rate limiting (30 req/min per IP) Winston structured logging Graceful shutdown handling All required endpoints (health, upload, download, info) Environment variable validation Comprehensive error handling

Plan complete and saved to docs/superpowers/plans/2026-05-17-telegram-uploader-backend-plan.md

Two execution options:

1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration

2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints

Which approach?