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 fileGET /f/:public_id- Download redirectGET /file/:public_id/info- File metadataGET /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?