feat: enhance file upload handling with improved file detection, size limits, and response formatting

This commit is contained in:
MythEclipse
2026-05-21 22:31:55 +07:00
parent 06844f1fa1
commit 52340ee77d
20 changed files with 696 additions and 539 deletions
+49 -67
View File
@@ -1,11 +1,36 @@
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { type Context, Telegraf } from 'telegraf';
import { db, files as fileSchema } from './db';
import { findFileByUniqueId } from './db/files';
import { config } from './env';
import {
detectFileType,
extractFileFromMessage,
getErrorMessage,
getFileSizeLimit,
type TelegramMediaMessage,
} from './utils/file';
import logger from './utils/logger';
import { forwardToStorage } from './utils/telegram';
type BotContext = {
message: TelegramMediaMessage;
from: { id: number };
chat?: { id: number };
reply: (text: string, extra?: { reply_parameters: { message_id: number } }) => Promise<unknown>;
};
type MediaEventRegistrar = {
on: (events: string[], handler: (ctx: BotContext) => Promise<unknown>) => void;
};
const replyWithDownloadUrl = async (ctx: BotContext, publicId: string): Promise<void> => {
const url = `${config.baseUrl}/f/${publicId}`;
await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, {
reply_parameters: { message_id: ctx.message.message_id },
});
};
export const startBot = async (): Promise<Telegraf<Context>> => {
try {
const bot = new Telegraf(config.botToken);
@@ -17,44 +42,15 @@ export const startBot = async (): Promise<Telegraf<Context>> => {
);
});
// Cast bot.on elements individually or explicitly as any to bypass Telegraf v4 typescript deprecation warnings on array syntax
(bot as any).on(
const mediaBot = bot as unknown as MediaEventRegistrar;
mediaBot.on(
['document', 'photo', 'video', 'audio', 'voice', 'animation', 'sticker', 'video_note'],
async (ctx: any) => {
async (ctx) => {
try {
const fileType:
| 'document'
| 'photo'
| 'video'
| 'audio'
| 'voice'
| 'animation'
| 'sticker'
| 'video_note' = ctx.message.document
? 'document'
: ctx.message.photo
? 'photo'
: ctx.message.video
? 'video'
: ctx.message.audio
? 'audio'
: ctx.message.voice
? 'voice'
: ctx.message.animation
? 'animation'
: ctx.message.sticker
? 'sticker'
: ctx.message.video_note
? 'video_note'
: 'document';
const fileObj =
fileType === 'photo'
? ctx.message.photo.slice(-1)[0]
: fileType === 'sticker'
? ctx.message.sticker
: ctx.message[fileType];
const { file_id, file_size, mime_type } = fileObj;
const fileType = detectFileType(ctx.message);
const fileObj = extractFileFromMessage(ctx.message, fileType);
const { file_id, mime_type } = fileObj;
const fileSize = fileObj.file_size || 0;
const fileName =
ctx.message.document?.file_name ||
ctx.message.photo?.slice(-1)[0]?.file_name ||
@@ -63,32 +59,18 @@ export const startBot = async (): Promise<Telegraf<Context>> => {
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;
const maxSize = getFileSizeLimit(fileType);
if (file_size > maxSize) {
if (fileSize > maxSize) {
return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`);
}
const existing = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.telegramFileUniqueId, fileObj.file_unique_id))
.limit(1);
const existing = await findFileByUniqueId(fileObj.file_unique_id);
if (existing && existing.length > 0) {
const url = `${config.baseUrl}/f/${existing[0].publicId}`;
await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, {
reply_parameters: { message_id: ctx.message.message_id },
});
if (existing) {
await replyWithDownloadUrl(ctx, existing.publicId);
logger.info('Duplicate file detected in bot, returned existing link', {
publicId: existing[0].publicId,
publicId: existing.publicId,
fileType,
fileName,
uploader: ctx.from.id,
@@ -107,7 +89,7 @@ export const startBot = async (): Promise<Telegraf<Context>> => {
storageMessageId: result.storageMessageId,
fileName: fileName,
mimeType: mime_type || 'application/octet-stream',
sizeBytes: file_size,
sizeBytes: fileSize,
fileType: fileType,
uploaderId: ctx.from.id,
createdAt: new Date(),
@@ -116,10 +98,7 @@ export const startBot = async (): Promise<Telegraf<Context>> => {
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 },
});
await replyWithDownloadUrl(ctx, publicId);
logger.info('File uploaded via bot', {
publicId,
@@ -127,8 +106,11 @@ export const startBot = async (): Promise<Telegraf<Context>> => {
fileName,
uploader: ctx.from.id,
});
} catch (error: any) {
logger.error('Bot file handler error', { error: error.message, chat_id: ctx.chat?.id });
} catch (error: unknown) {
logger.error('Bot file handler error', {
error: getErrorMessage(error),
chat_id: ctx.chat?.id,
});
await ctx.reply('❌ Gagal mengupload file. Coba lagi nanti.');
}
},
@@ -136,7 +118,7 @@ export const startBot = async (): Promise<Telegraf<Context>> => {
bot.use((ctx, next) => {
logger.info('Telegram event received', {
type: (ctx.update as any).type,
type: 'type' in ctx.update ? ctx.update.type : undefined,
chat_id: ctx.chat?.id,
});
return next();
@@ -147,8 +129,8 @@ export const startBot = async (): Promise<Telegraf<Context>> => {
logger.info('Telegram bot started', { botToken: `${config.botToken?.substring(0, 10)}...` });
return bot;
} catch (error: any) {
logger.error('Failed to start bot', { error: error.message });
} catch (error: unknown) {
logger.error('Failed to start bot', { error: getErrorMessage(error) });
throw error;
}
};
+26
View File
@@ -0,0 +1,26 @@
import { eq } from 'drizzle-orm';
import { db, files as fileSchema } from './index';
import type { File } from './schema';
export const findFileByHash = async (hash: string): Promise<File | null> => {
const result = await db.select().from(fileSchema).where(eq(fileSchema.fileHash, hash)).limit(1);
return result[0] || null;
};
export const findFileByPublicId = async (publicId: string): Promise<File | null> => {
const result = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.publicId, publicId))
.limit(1);
return result[0] || null;
};
export const findFileByUniqueId = async (telegramFileUniqueId: string): Promise<File | null> => {
const result = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.telegramFileUniqueId, telegramFileUniqueId))
.limit(1);
return result[0] || null;
};
+3 -2
View File
@@ -1,5 +1,6 @@
import postgres from 'postgres';
import { config } from '../env';
import { getErrorMessage } from '../utils/file';
import logger from '../utils/logger';
const schemaSql = await Bun.file('schema.sql').text();
@@ -8,8 +9,8 @@ const sql = postgres(config.databaseUrl, { max: 1 });
try {
await sql.unsafe(schemaSql);
logger.info('Database migration completed');
} catch (error: any) {
logger.error('Database migration failed', { error: error.message });
} catch (error: unknown) {
logger.error('Database migration failed', { error: getErrorMessage(error) });
process.exitCode = 1;
} finally {
await sql.end();
+11 -25
View File
@@ -1,5 +1,5 @@
import { eq } from 'drizzle-orm';
import { db, files as fileSchema } from '../db';
import { findFileByPublicId } from '../db/files';
import { formatCreatedAt, getErrorMessage } from '../utils/file';
import logger from '../utils/logger';
import { checkRateLimit } from '../utils/rateLimit';
@@ -18,18 +18,13 @@ export const handleFileRedirect = async (req: RequestWithParams): Promise<Respon
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
const result = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.publicId, public_id))
.limit(1);
const file = await findFileByPublicId(public_id);
if (!result.length) {
if (!file) {
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);
@@ -41,8 +36,8 @@ export const handleFileRedirect = async (req: RequestWithParams): Promise<Respon
Location: redirectUrl,
},
});
} catch (error: any) {
logger.error('File redirect error', { public_id, error: error.message });
} catch (error: unknown) {
logger.error('File redirect error', { public_id, error: getErrorMessage(error) });
return Response.json({ error: 'Server error' }, { status: 500 });
}
};
@@ -54,18 +49,12 @@ export const handleFileInfo = async (req: RequestWithParams): Promise<Response>
return Response.json({ error: 'Missing file id' }, { status: 400 });
}
const result = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.publicId, public_id))
.limit(1);
const file = await findFileByPublicId(public_id);
if (!result.length) {
if (!file) {
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,
@@ -74,15 +63,12 @@ export const handleFileInfo = async (req: RequestWithParams): Promise<Response>
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(),
created_at: formatCreatedAt(file.createdAt),
},
{ status: 200 },
);
} catch (error: any) {
logger.error('File info error', { public_id, error: error.message });
} catch (error: unknown) {
logger.error('File info error', { public_id, error: getErrorMessage(error) });
return Response.json({ error: 'Server error' }, { status: 500 });
}
};
+5 -3
View File
@@ -1,13 +1,15 @@
import { sql } from 'drizzle-orm';
import { db } from '../db';
import { getErrorMessage } from '../utils/file';
import logger from '../utils/logger';
export const handleHealth = async (_req: Request): Promise<Response> => {
try {
await db.execute(sql`SELECT 1`);
return Response.json({ status: 'ok' }, { status: 200 });
} catch (error: any) {
logger.error('Health check failed', { error: error.message });
return Response.json({ status: 'error', error: error.message }, { status: 500 });
} catch (error: unknown) {
const message = getErrorMessage(error);
logger.error('Health check failed', { error: message });
return Response.json({ status: 'error', error: message }, { status: 500 });
}
};
+43 -58
View File
@@ -11,6 +11,45 @@ const jsonContent = (schema: object) => ({
'application/json': { schema },
});
const publicIdParameter = {
name: 'public_id',
in: 'path',
required: true,
description: 'Permanent public file ID.',
schema: { type: 'string' },
};
const fileInfoProperties = {
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',
},
};
const uploadProperties = {
...fileInfoProperties,
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 },
download_url: {
type: 'string',
example: `${config.baseUrl}/f/xYz123`,
},
};
const objectSchema = (properties: object) => ({
type: 'object',
properties,
});
export const handleSwaggerJson = async (): Promise<Response> => {
const spec = {
openapi: '3.0.0',
@@ -99,30 +138,7 @@ export const handleSwaggerJson = async (): Promise<Response> => {
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`,
},
},
}),
content: jsonContent(objectSchema(uploadProperties)),
},
'400': {
description: 'Bad request.',
@@ -140,15 +156,7 @@ export const handleSwaggerJson = async (): Promise<Response> => {
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' },
},
],
parameters: [publicIdParameter],
responses: {
'302': {
description: 'Redirect to Telegram CDN URL.',
@@ -180,34 +188,11 @@ export const handleSwaggerJson = async (): Promise<Response> => {
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' },
},
],
parameters: [publicIdParameter],
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',
},
},
}),
content: jsonContent(objectSchema(fileInfoProperties)),
},
'400': {
description: 'Missing public ID.',
+97 -181
View File
@@ -1,18 +1,89 @@
import { createReadStream, unlinkSync } from 'node:fs';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { db, files as fileSchema } from '../db';
import { findFileByHash } from '../db/files';
import type { NewFile } from '../db/schema';
import { config } from '../env';
import {
buildUploadResponse,
checkFileSize,
computeHash,
ensureExtension,
extractMimeType,
getErrorMessage,
getFileType,
} from '../utils/file';
import logger from '../utils/logger';
import { forwardToStorage, getBot } from '../utils/telegram';
type UploadedFile = NewFile & {
createdAt: Date;
updatedAt: Date;
};
type TelegramFileLookup = {
mime_type?: string;
file_size?: number;
};
interface JsonUploadPayload {
file?: unknown;
fileName?: string;
}
const parseBase64File = (file: string): { base64Data: string; mimeType: string } => {
if (!file.startsWith('data:')) {
return { base64Data: file, mimeType: 'application/octet-stream' };
}
const match = file.match(/^data:([^;]+);base64,(.+)$/);
return match
? { base64Data: match[2], mimeType: match[1] }
: { base64Data: file, mimeType: 'application/octet-stream' };
};
const normalizeFileType = (mimeType: string, fileName: string): string => {
const fileType = getFileType(mimeType, fileName);
return fileType === 'application' ? 'document' : fileType;
};
const performUpload = async (
fileBuffer: Buffer,
fileName: string,
mimeType: string,
): Promise<UploadedFile> => {
const tempPath = `/tmp/teleuploader-${nanoid()}`;
try {
await Bun.write(tempPath, fileBuffer);
const fileStream = createReadStream(tempPath);
const result = await forwardToStorage(fileStream, fileName, getFileType(mimeType, fileName));
const bot = getBot();
const fileInfo = (await bot.telegram.getFile(result.telegramFileId)) as TelegramFileLookup;
return {
publicId: nanoid(),
telegramFileId: result.telegramFileId,
telegramFileUniqueId: result.telegramFileUniqueId,
storageChatId: config.storageChatId,
storageMessageId: result.storageMessageId,
fileName,
mimeType: fileInfo.mime_type || mimeType || 'application/octet-stream',
sizeBytes: fileInfo.file_size || fileBuffer.byteLength,
fileType: getFileType(mimeType, fileName),
uploaderId: 0,
fileHash: computeHash(fileBuffer),
createdAt: new Date(),
updatedAt: new Date(),
};
} finally {
setTimeout(() => {
try {
unlinkSync(tempPath);
} catch {}
}, 50);
}
};
export const handleUpload = async (req: Request): Promise<Response> => {
try {
const contentType = req.headers.get('content-type') || '';
@@ -27,14 +98,14 @@ export const handleUpload = async (req: Request): Promise<Response> => {
{ error: 'Unsupported content type. Use multipart/form-data or application/json' },
{ status: 400 },
);
} catch (error: any) {
logger.error('Upload error', { error: error.message });
return Response.json({ error: error.message }, { status: 500 });
} catch (error: unknown) {
const message = getErrorMessage(error);
logger.error('Upload error', { error: message });
return Response.json({ error: message }, { status: 500 });
}
};
const handleMultipartUpload = async (req: Request): Promise<Response> => {
let tempPath = '';
try {
const formData = await req.formData();
const file = formData.get('file');
@@ -49,33 +120,9 @@ const handleMultipartUpload = async (req: Request): Promise<Response> => {
const fileBuffer = Buffer.from(fileBytes);
const hash = computeHash(fileBuffer);
// Check for duplicate in DB
const existing = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.fileHash, hash))
.limit(1);
if (existing.length > 0) {
const existingFile = existing[0];
const responsePayload = {
public_id: existingFile.publicId,
telegram_file_id: existingFile.telegramFileId,
telegram_file_unique_id: existingFile.telegramFileUniqueId,
storage_chat_id: existingFile.storageChatId,
storage_message_id: existingFile.storageMessageId,
file_name: existingFile.fileName,
mime_type: existingFile.mimeType,
size_bytes: existingFile.sizeBytes,
file_type: existingFile.fileType,
uploader_id: existingFile.uploaderId,
created_at:
existingFile.createdAt instanceof Date
? existingFile.createdAt.toISOString()
: new Date(existingFile.createdAt).toISOString(),
download_url: `${config.baseUrl}/f/${existingFile.publicId}`,
};
return Response.json(responsePayload, { status: 200 });
const existingFile = await findFileByHash(hash);
if (existingFile) {
return Response.json(buildUploadResponse(existingFile, config.baseUrl), { status: 200 });
}
const rawMimeType = file.type || extractMimeType({}, req) || 'application/octet-stream';
@@ -90,68 +137,20 @@ const handleMultipartUpload = async (req: Request): Promise<Response> => {
return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 });
}
// Write file to disk temporarily
tempPath = `/tmp/teleuploader-${nanoid()}`;
await Bun.write(tempPath, fileBuffer);
const fileStream = createReadStream(tempPath);
const result = await forwardToStorage(fileStream, finalFileName, fileType);
const bot = getBot();
const fileInfo = (await bot.telegram.getFile(result.telegramFileId)) as any;
const uploaded = {
publicId: nanoid(),
telegramFileId: result.telegramFileId,
telegramFileUniqueId: result.telegramFileUniqueId,
storageChatId: config.storageChatId,
storageMessageId: result.storageMessageId,
fileName: finalFileName,
mimeType: fileInfo.mime_type || mimeType || 'application/octet-stream',
sizeBytes: fileInfo.file_size || fileBuffer.byteLength,
fileType: fileType,
uploaderId: 0,
fileHash: hash,
createdAt: new Date(),
updatedAt: new Date(),
};
const uploaded = await performUpload(fileBuffer, finalFileName, mimeType);
await db.insert(fileSchema).values(uploaded);
const responsePayload = {
public_id: uploaded.publicId,
telegram_file_id: uploaded.telegramFileId,
telegram_file_unique_id: uploaded.telegramFileUniqueId,
storage_chat_id: uploaded.storageChatId,
storage_message_id: uploaded.storageMessageId,
file_name: uploaded.fileName,
mime_type: uploaded.mimeType,
size_bytes: uploaded.sizeBytes,
file_type: uploaded.fileType,
uploader_id: uploaded.uploaderId,
created_at: uploaded.createdAt.toISOString(),
download_url: `${config.baseUrl}/f/${uploaded.publicId}`,
};
return Response.json(responsePayload, { status: 200 });
} catch (error: any) {
logger.error('Multipart upload error', { error: error.message });
return Response.json({ error: error.message }, { status: 500 });
} finally {
if (tempPath) {
const p = tempPath;
setTimeout(() => {
try {
unlinkSync(p);
} catch {}
}, 50);
}
return Response.json(buildUploadResponse(uploaded, config.baseUrl), { status: 200 });
} catch (error: unknown) {
const message = getErrorMessage(error);
logger.error('Multipart upload error', { error: message });
return Response.json({ error: message }, { status: 500 });
}
};
const handleJSONUpload = async (req: Request): Promise<Response> => {
let tempPath = '';
try {
const { file, fileName = 'file' } = (await req.json()) as any;
const { file, fileName = 'file' } = (await req.json()) as JsonUploadPayload;
if (!file || typeof file !== 'string') {
return Response.json(
@@ -160,112 +159,29 @@ const handleJSONUpload = async (req: Request): Promise<Response> => {
);
}
let base64Data = file;
let rawMimeType = 'application/octet-stream';
if (file.startsWith('data:')) {
const match = file.match(/^data:([^;]+);base64,(.+)$/);
if (match) {
rawMimeType = match[1];
base64Data = match[2];
}
}
const { base64Data, mimeType: rawMimeType } = parseBase64File(file);
const fileBytes = Buffer.from(base64Data, 'base64');
const hash = computeHash(fileBytes);
// Check for duplicate in DB
const existing = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.fileHash, hash))
.limit(1);
if (existing.length > 0) {
const existingFile = existing[0];
const responsePayload = {
public_id: existingFile.publicId,
telegram_file_id: existingFile.telegramFileId,
telegram_file_unique_id: existingFile.telegramFileUniqueId,
storage_chat_id: existingFile.storageChatId,
storage_message_id: existingFile.storageMessageId,
file_name: existingFile.fileName,
mime_type: existingFile.mimeType,
size_bytes: existingFile.sizeBytes,
file_type: existingFile.fileType,
uploader_id: existingFile.uploaderId,
created_at:
existingFile.createdAt instanceof Date
? existingFile.createdAt.toISOString()
: new Date(existingFile.createdAt).toISOString(),
download_url: `${config.baseUrl}/f/${existingFile.publicId}`,
};
return Response.json(responsePayload, { status: 200 });
const existingFile = await findFileByHash(hash);
if (existingFile) {
return Response.json(buildUploadResponse(existingFile, config.baseUrl), { status: 200 });
}
const { fileName: finalFileName, mimeType } = ensureExtension(fileName, fileBytes, rawMimeType);
const fileType =
getFileType(mimeType, finalFileName) === 'application'
? 'document'
: getFileType(mimeType, finalFileName);
const fileType = normalizeFileType(mimeType, finalFileName);
if (!checkFileSize(fileBytes.byteLength, fileType)) {
return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 });
}
// Write file to disk temporarily
tempPath = `/tmp/teleuploader-${nanoid()}`;
await Bun.write(tempPath, fileBytes);
const fileStream = createReadStream(tempPath);
const result = await forwardToStorage(fileStream, finalFileName, fileType);
const bot = getBot();
const fileInfo = (await bot.telegram.getFile(result.telegramFileId)) as any;
const uploaded = {
publicId: nanoid(),
telegramFileId: result.telegramFileId,
telegramFileUniqueId: result.telegramFileUniqueId,
storageChatId: config.storageChatId,
storageMessageId: result.storageMessageId,
fileName: finalFileName,
mimeType: fileInfo.mime_type || mimeType || 'application/octet-stream',
sizeBytes: fileInfo.file_size || fileBytes.byteLength,
fileType: fileType,
uploaderId: 0,
fileHash: hash,
createdAt: new Date(),
updatedAt: new Date(),
};
const uploaded = await performUpload(fileBytes, finalFileName, mimeType);
await db.insert(fileSchema).values(uploaded);
const responsePayload = {
public_id: uploaded.publicId,
telegram_file_id: uploaded.telegramFileId,
telegram_file_unique_id: uploaded.telegramFileUniqueId,
storage_chat_id: uploaded.storageChatId,
storage_message_id: uploaded.storageMessageId,
file_name: uploaded.fileName,
mime_type: uploaded.mimeType,
size_bytes: uploaded.sizeBytes,
file_type: uploaded.fileType,
uploader_id: uploaded.uploaderId,
created_at: uploaded.createdAt.toISOString(),
download_url: `${config.baseUrl}/f/${uploaded.publicId}`,
};
return Response.json(responsePayload, { status: 200 });
} catch (error: any) {
logger.error('JSON upload error', { error: error.message });
return Response.json({ error: error.message }, { status: 500 });
} finally {
if (tempPath) {
const p = tempPath;
setTimeout(() => {
try {
unlinkSync(p);
} catch {}
}, 50);
}
return Response.json(buildUploadResponse(uploaded, config.baseUrl), { status: 200 });
} catch (error: unknown) {
const message = getErrorMessage(error);
logger.error('JSON upload error', { error: message });
return Response.json({ error: message }, { status: 500 });
}
};
+137 -8
View File
@@ -1,3 +1,21 @@
export const getErrorMessage = (error: unknown): string => {
return error instanceof Error ? error.message : String(error);
};
interface FileMetadata {
publicId: string;
telegramFileId: string;
telegramFileUniqueId: string;
storageChatId: number;
storageMessageId: number;
fileName: string;
mimeType: string;
sizeBytes: number;
fileType: string;
uploaderId: number;
createdAt: Date | string | number;
}
const FILE_TYPES: Record<string, number> = {
document: 2 * 1024 * 1024 * 1024, // 2GB
photo: 10 * 1024 * 1024, // 10MB
@@ -72,10 +90,41 @@ export const ensureExtension = (
return { fileName: finalFileName, mimeType };
};
export const extractFileName = (msg: any, request: any): string => {
if (request?.headers?.['x-file-name']) {
return request.headers['x-file-name'];
}
type HeaderMapRequest = {
headers?:
| {
get?: (name: string) => string | null;
}
| Record<string, string>;
};
type FileLike = {
fileName?: string;
mimeType?: string;
};
type MessageLike = {
document?: FileLike;
photo?: FileLike[];
audio?: FileLike;
voice?: FileLike;
animation?: FileLike;
};
const getHeader = (request: HeaderMapRequest | null, name: string): string | undefined => {
const headers = request?.headers;
if (!headers) return undefined;
const get = 'get' in headers ? headers.get : undefined;
if (typeof get === 'function') return get(name) || undefined;
return (headers as Record<string, string>)[name];
};
export const extractFileName = (msg: MessageLike, request: HeaderMapRequest | null): string => {
const headerFileName = getHeader(request, 'x-file-name');
if (headerFileName) return headerFileName;
return (
msg.document?.fileName ||
msg.photo?.slice(-1)[0]?.fileName ||
@@ -86,10 +135,10 @@ export const extractFileName = (msg: any, request: any): string => {
);
};
export const extractMimeType = (msg: any, request: any): string => {
if (request?.headers?.['x-mime-type']) {
return request.headers['x-mime-type'];
}
export const extractMimeType = (msg: MessageLike, request: HeaderMapRequest | null): string => {
const headerMimeType = getHeader(request, 'x-mime-type');
if (headerMimeType) return headerMimeType;
return (
msg.document?.mimeType ||
msg.photo?.slice(-1)[0]?.mimeType ||
@@ -105,3 +154,83 @@ export const computeHash = (buffer: Buffer): string => {
hasher.update(buffer);
return hasher.digest('hex');
};
export interface TelegramMessageFile {
file_id: string;
file_unique_id: string;
file_size?: number;
mime_type?: string;
file_name?: string;
}
export interface TelegramMediaMessage {
message_id: number;
document?: TelegramMessageFile;
photo?: TelegramMessageFile[];
video?: TelegramMessageFile;
audio?: TelegramMessageFile;
voice?: TelegramMessageFile;
animation?: TelegramMessageFile;
sticker?: TelegramMessageFile;
video_note?: TelegramMessageFile;
}
export const extractFileFromMessage = (
msg: TelegramMediaMessage,
fileType: string,
): TelegramMessageFile => {
if (fileType === 'photo') return msg.photo?.slice(-1)[0] as TelegramMessageFile;
if (fileType === 'sticker') return msg.sticker as TelegramMessageFile;
return msg[fileType as keyof TelegramMediaMessage] as TelegramMessageFile;
};
export const detectFileType = (msg: TelegramMediaMessage): string => {
if (msg.document) return 'document';
if (msg.photo) return 'photo';
if (msg.video) return 'video';
if (msg.audio) return 'audio';
if (msg.voice) return 'voice';
if (msg.animation) return 'animation';
if (msg.sticker) return 'sticker';
if (msg.video_note) return 'video_note';
return 'document';
};
export const getFileSizeLimit = (fileType: string): number =>
FILE_TYPES[fileType] || FILE_TYPES.document;
export const formatCreatedAt = (createdAt: Date | string | number): string => {
return createdAt instanceof Date ? createdAt.toISOString() : new Date(createdAt).toISOString();
};
export interface UploadResponse {
public_id: string;
telegram_file_id: string;
telegram_file_unique_id: string;
storage_chat_id: number;
storage_message_id: number;
file_name: string;
mime_type: string;
size_bytes: number;
file_type: string;
uploader_id: number;
created_at: string;
download_url: string;
}
export const buildUploadResponse = (file: FileMetadata, baseUrl: string): UploadResponse => {
return {
public_id: file.publicId,
telegram_file_id: file.telegramFileId,
telegram_file_unique_id: file.telegramFileUniqueId,
storage_chat_id: file.storageChatId,
storage_message_id: file.storageMessageId,
file_name: file.fileName,
mime_type: file.mimeType,
size_bytes: file.sizeBytes,
file_type: file.fileType,
uploader_id: file.uploaderId,
created_at: formatCreatedAt(file.createdAt),
download_url: `${baseUrl}/f/${file.publicId}`,
};
};
+143 -90
View File
@@ -10,28 +10,35 @@ const TELEGRAM_API_URL = `https://api.telegram.org/bot${config.botToken}/`;
let currentBotIndex = 0;
const executeWithBotRetry = async (
action: (botInstance: Telegraf) => Promise<any>,
const rotateBot = (): { previousIndex: number; nextIndex: number } => {
const previousIndex = currentBotIndex;
currentBotIndex = (currentBotIndex + 1) % bots.length;
return { previousIndex, nextIndex: currentBotIndex };
};
const sleep = (seconds: number): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
};
const executeWithBotRetry = async <T>(
action: (botInstance: Telegraf) => Promise<T>,
retries = 5,
attemptedBots = 0,
): Promise<any> => {
): Promise<T> => {
const currentBot = bots[currentBotIndex];
try {
return await action(currentBot);
} catch (error: any) {
const errorStr = error.message || String(error);
} catch (error: unknown) {
const errorStr = error instanceof Error ? error.message : String(error);
const match = errorStr.match(/retry after (\d+)/i);
if (match) {
// 429 rate limit hit! Rotate bot index instantly
const prevIndex = currentBotIndex;
currentBotIndex = (currentBotIndex + 1) % bots.length;
const nextIndex = currentBotIndex;
const { previousIndex, nextIndex } = rotateBot();
attemptedBots++;
if (attemptedBots < bots.length) {
logger.info(
`Bot Index ${prevIndex} hit 429. Instantly rotating to Bot Index ${nextIndex}...`,
`Bot Index ${previousIndex} hit 429. Instantly rotating to Bot Index ${nextIndex}...`,
);
return executeWithBotRetry(action, retries, attemptedBots);
}
@@ -42,7 +49,7 @@ const executeWithBotRetry = async (
logger.warn(`All bots in the pool are rate-limited. Sleeping for ${seconds} seconds...`, {
error: errorStr,
});
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
await sleep(seconds);
return executeWithBotRetry(action, retries - 1, 0);
}
}
@@ -62,61 +69,121 @@ interface TelegramFileInfo {
file_path: string;
}
interface TelegramGetFileResponse {
ok: boolean;
description?: string;
result: {
file_id: string;
};
}
interface TelegramGetInfoResponse {
ok: boolean;
description?: string;
result: TelegramFileInfo;
}
interface UploadedTelegramFile {
file_id?: string;
file_unique_id?: string;
}
interface TelegramMessageResult {
message_id: number;
document?: UploadedTelegramFile;
photo?: UploadedTelegramFile[];
video?: UploadedTelegramFile;
audio?: UploadedTelegramFile;
voice?: UploadedTelegramFile;
animation?: UploadedTelegramFile;
sticker?: UploadedTelegramFile;
video_note?: UploadedTelegramFile;
[key: string]: unknown;
}
type FilePayload = { source: unknown; filename: string };
type SendPayload = { caption?: string };
type SendMethod = (
chatId: number,
filePayload: FilePayload,
payload?: SendPayload,
) => Promise<TelegramMessageResult>;
const sendMethodMap: Record<string, keyof Telegraf['telegram']> = {
photo: 'sendPhoto',
audio: 'sendAudio',
video: 'sendVideo',
voice: 'sendVoice',
animation: 'sendAnimation',
sticker: 'sendSticker',
document: 'sendDocument',
video_note: 'sendDocument',
};
const extractUploadedFile = (
result: TelegramMessageResult,
fileType: string,
): UploadedTelegramFile | undefined => {
if (result.document) return result.document;
if (result.photo) return result.photo?.slice(-1)[0];
if (result.video) return result.video;
if (result.audio) return result.audio;
if (result.voice) return result.voice;
if (result.animation) return result.animation;
if (result.sticker) return result.sticker;
if (result.video_note) return result.video_note;
return result[fileType] as UploadedTelegramFile | undefined;
};
const buildSendPayload = (fileType: string, fileName: string): SendPayload => {
const basePayload = { caption: fileName };
if (fileType === 'sticker') return {};
if (fileType === 'document') return { caption: `📁 ${fileName}` };
return basePayload;
};
const getMediaGroupType = (fileType: string): string => {
if (fileType === 'photo') return 'photo';
if (fileType === 'video') return 'video';
if (fileType === 'audio') return 'audio';
return 'document';
};
interface MediaGroupPayloadItem {
type: string;
media: string;
caption: string;
}
const buildMediaGroup = (items: MediaGroupItem[]): MediaGroupPayloadItem[] => {
return items.map((item) => ({
type: getMediaGroupType(item.fileType),
media: item.fileId,
caption: item.fileName,
}));
};
export const forwardToStorage = async (
fileChunk: any,
fileChunk: unknown,
fileName: string,
fileType: string,
): Promise<ForwardResult> => {
try {
const result: any = await enqueueUpload(async () => {
const result = await enqueueUpload(async (): Promise<TelegramMessageResult> => {
const filePayload = { source: fileChunk, filename: fileName };
const sendMethod = sendMethodMap[fileType] || 'sendDocument';
const payload = buildSendPayload(fileType, fileName);
const uploadResult = await executeWithBotRetry((activeBot) => {
if (fileType === 'photo') {
return activeBot.telegram.sendPhoto(config.storageChatId, filePayload, {
caption: fileName,
});
} else if (fileType === 'audio') {
return activeBot.telegram.sendAudio(config.storageChatId, filePayload, {
caption: fileName,
});
} else if (fileType === 'video') {
return activeBot.telegram.sendVideo(config.storageChatId, filePayload, {
caption: fileName,
});
} else if (fileType === 'voice') {
return activeBot.telegram.sendVoice(config.storageChatId, filePayload, {
caption: fileName,
});
} else if (fileType === 'animation') {
return activeBot.telegram.sendAnimation(config.storageChatId, filePayload, {
caption: fileName,
});
} else if (fileType === 'sticker') {
return activeBot.telegram.sendSticker(config.storageChatId, filePayload);
} else {
return activeBot.telegram.sendDocument(config.storageChatId, filePayload, {
caption: `📁 ${fileName}`,
});
}
const telegram = activeBot.telegram as unknown as Record<string, SendMethod>;
return telegram[sendMethod](config.storageChatId, filePayload, payload);
});
// Advance round-robin index for next job
currentBotIndex = (currentBotIndex + 1) % bots.length;
return uploadResult;
});
let uploadedFile: any;
if (result.document) uploadedFile = result.document;
else if (result.photo) uploadedFile = result.photo?.slice(-1)[0];
else if (result.video) uploadedFile = result.video;
else if (result.audio) uploadedFile = result.audio;
else if (result.voice) uploadedFile = result.voice;
else if (result.animation) uploadedFile = result.animation;
else if (result.sticker) uploadedFile = result.sticker;
else if (result.video_note) uploadedFile = result.video_note;
else uploadedFile = result[fileType];
const uploadedFile = extractUploadedFile(result, fileType);
logger.info('File forwarded to storage', { fileName, message: result.message_id });
return {
@@ -124,8 +191,11 @@ export const forwardToStorage = async (
telegramFileUniqueId: uploadedFile?.file_unique_id || '',
storageMessageId: result.message_id,
};
} catch (error: any) {
logger.error('Failed to forward file to storage', { fileName, error: error.message });
} catch (error: unknown) {
logger.error('Failed to forward file to storage', {
fileName,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
};
@@ -144,26 +214,18 @@ export const forwardMediaGroupToStorage = async (
telegramFileUniqueIds: string[];
}> => {
try {
const result: any = await enqueueUpload(async () => {
const mediaGroup: any = items.map((item) => {
let type: 'photo' | 'video' | 'audio' | 'document' = 'document';
if (item.fileType === 'photo') type = 'photo';
else if (item.fileType === 'video') type = 'video';
else if (item.fileType === 'audio') type = 'audio';
return {
type,
media: item.fileId,
caption: item.fileName,
};
});
const result = await enqueueUpload(async (): Promise<TelegramMessageResult[]> => {
const mediaGroup = buildMediaGroup(items);
const uploadResult = await executeWithBotRetry((activeBot) => {
return activeBot.telegram.sendMediaGroup(config.storageChatId, mediaGroup);
const sendMediaGroup = activeBot.telegram.sendMediaGroup as unknown as (
chatId: number,
media: MediaGroupPayloadItem[],
) => Promise<TelegramMessageResult[]>;
return sendMediaGroup(config.storageChatId, mediaGroup);
});
currentBotIndex = (currentBotIndex + 1) % bots.length;
return uploadResult;
});
@@ -174,20 +236,7 @@ export const forwardMediaGroupToStorage = async (
const telegramFileUniqueIds: string[] = [];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const fileType = items[i]?.fileType || 'document';
let uploadedFile: any;
if (msg.document) uploadedFile = msg.document;
else if (msg.photo) uploadedFile = msg.photo?.slice(-1)[0];
else if (msg.video) uploadedFile = msg.video;
else if (msg.audio) uploadedFile = msg.audio;
else if (msg.voice) uploadedFile = msg.voice;
else if (msg.animation) uploadedFile = msg.animation;
else if (msg.sticker) uploadedFile = msg.sticker;
else if (msg.video_note) uploadedFile = msg.video_note;
else uploadedFile = msg[fileType];
const uploadedFile = extractUploadedFile(messages[i], items[i]?.fileType || 'document');
telegramFileIds.push(uploadedFile?.file_id || '');
telegramFileUniqueIds.push(uploadedFile?.file_unique_id || '');
}
@@ -197,8 +246,10 @@ export const forwardMediaGroupToStorage = async (
telegramFileIds,
telegramFileUniqueIds,
};
} catch (error: any) {
logger.error('Failed to forward media group to storage', { error: error.message });
} catch (error: unknown) {
logger.error('Failed to forward media group to storage', {
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
};
@@ -209,7 +260,7 @@ export const getFileInfo = async (
): Promise<TelegramFileInfo> => {
try {
const result = await fetch(`${TELEGRAM_API_URL}getFile`);
const data: any = await result.json();
const data = (await result.json()) as TelegramGetFileResponse;
if (!data.ok) {
throw new Error(data.description || 'Telegram API error');
@@ -221,7 +272,7 @@ export const getFileInfo = async (
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_id: fileId }),
});
const fileInfo: any = await fileResult.json();
const fileInfo = (await fileResult.json()) as TelegramGetInfoResponse;
if (!fileInfo.ok) {
throw new Error(fileInfo.description || 'Telegram info error');
@@ -232,8 +283,10 @@ export const getFileInfo = async (
mime_type: fileInfo.result.mime_type,
file_path: fileInfo.result.file_path,
};
} catch (error: any) {
logger.error('Failed to get file info', { error: error.message });
} catch (error: unknown) {
logger.error('Failed to get file info', {
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
};
+14 -3
View File
@@ -1,7 +1,17 @@
// @ts-nocheck
import { afterAll, beforeEach, describe, expect, it, mock } from 'bun:test';
const mockServe = mock((options) => {
type ServeOptions = {
port?: number;
routes?: Record<string, unknown>;
};
type MockServer = {
port?: number;
routes?: Record<string, unknown>;
stop: ReturnType<typeof mock>;
};
const mockServe = mock((options: ServeOptions): MockServer => {
return {
port: options.port,
routes: options.routes,
@@ -10,7 +20,7 @@ const mockServe = mock((options) => {
});
const originalServe = Bun.serve;
Bun.serve = mockServe;
Bun.serve = mockServe as unknown as typeof Bun.serve;
const mockStartBot = mock(() =>
Promise.resolve({
@@ -58,6 +68,7 @@ describe('Bootstrap Server', () => {
const serveCallArgs = mockServe.mock.calls[0][0];
expect(serveCallArgs).toHaveProperty('port');
expect(serveCallArgs).toHaveProperty('routes');
expect(serveCallArgs.routes).toBeDefined();
expect(serveCallArgs.routes).toHaveProperty('/api/upload');
expect(serveCallArgs.routes).toHaveProperty('/f/:public_id');
expect(serveCallArgs.routes).toHaveProperty('/file/:public_id/info');
+59 -44
View File
@@ -1,5 +1,5 @@
// @ts-nocheck
import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import type { TelegramMediaMessage } from '../src/utils/file';
import logger from '../src/utils/logger';
// Mock environment
@@ -7,48 +7,66 @@ process.env.BOT_TOKEN = process.env.BOT_TOKEN || '123456789:ABCdefGhIJKlmNoPQRsT
process.env.STORAGE_CHANNEL_ID = process.env.STORAGE_CHANNEL_ID || '-1001234567890';
process.env.BASE_URL = process.env.BASE_URL || 'https://tele.asepharyana.tech';
type BotTestContext = {
message: TelegramMediaMessage;
from: { id: number };
reply: ReturnType<typeof mock>;
};
type BotFileHandler = (ctx: BotTestContext) => Promise<unknown>;
type StartHandler = (ctx: { reply: ReturnType<typeof mock> }) => Promise<unknown>;
const getStartHandler = (): StartHandler => {
return mockCommand.mock.calls.find((call) => call[0] === 'start')?.[1] as StartHandler;
};
const getFileHandler = (): BotFileHandler => {
return mockOn.mock.calls[0][1] as BotFileHandler;
};
// 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;
}
},
};
});
class MockTelegraf {
token: string;
launch = mockLaunch;
command = mockCommand;
on = mockOn;
use = mockUse;
constructor(token: string) {
this.token = token;
}
}
mock.module('telegraf', () => ({
Telegraf: MockTelegraf,
}));
// Mock database
const mockInsert = mock(() => ({
values: mock(() => Promise.resolve()),
}));
const mockLimit = mock(() => Promise.resolve([]));
const mockWhere = mock(() => ({
limit: mockLimit,
}));
const mockFrom = mock(() => ({
where: mockWhere,
}));
const mockSelect = mock(() => ({
from: mockFrom,
}));
type ExistingFile = {
publicId: string;
telegramFileId: string;
telegramFileUniqueId: string;
};
const mockFindFileByUniqueId = mock((): Promise<ExistingFile | null> => Promise.resolve(null));
mock.module('../src/db/index', () => ({
db: {
insert: mockInsert,
select: mockSelect,
},
files: {
telegramFileUniqueId: 'telegram_file_unique_id',
},
files: {},
}));
mock.module('../src/db/files', () => ({
findFileByUniqueId: mockFindFileByUniqueId,
}));
// Mock forwardToStorage
@@ -73,8 +91,8 @@ describe('Telegram Bot Handler', () => {
mockOn.mockClear();
mockUse.mockClear();
mockInsert.mockClear();
mockLimit.mockClear();
mockLimit.mockResolvedValue([]);
mockFindFileByUniqueId.mockClear();
mockFindFileByUniqueId.mockResolvedValue(null);
mockForwardToStorage.mockClear();
infoSpy.mockClear();
errorSpy.mockClear();
@@ -98,7 +116,7 @@ describe('Telegram Bot Handler', () => {
const { startBot } = await import('../src/bot');
await startBot();
const startHandler = mockCommand.mock.calls.find((call) => call[0] === 'start')[1];
const startHandler = getStartHandler();
const replyMock = mock(() => Promise.resolve());
const ctx = {
reply: replyMock,
@@ -112,7 +130,7 @@ describe('Telegram Bot Handler', () => {
const { startBot } = await import('../src/bot');
await startBot();
const fileHandler = mockOn.mock.calls[0][1];
const fileHandler = getFileHandler();
const replyMock = mock(() => Promise.resolve());
const ctx = {
message: {
@@ -144,7 +162,7 @@ describe('Telegram Bot Handler', () => {
const { startBot } = await import('../src/bot');
await startBot();
const fileHandler = mockOn.mock.calls[0][1];
const fileHandler = getFileHandler();
const replyMock = mock(() => Promise.resolve());
const ctx = {
message: {
@@ -173,17 +191,14 @@ describe('Telegram Bot Handler', () => {
const { startBot } = await import('../src/bot');
await startBot();
const fileHandler = mockOn.mock.calls[0][1];
const fileHandler = getFileHandler();
const replyMock = mock(() => Promise.resolve());
// Mock DB to return an existing match
mockLimit.mockResolvedValueOnce([
{
publicId: 'already_exists_abc',
telegramFileId: 'stored_file_id',
telegramFileUniqueId: 'doc_uniq_123',
},
]);
mockFindFileByUniqueId.mockResolvedValueOnce({
publicId: 'already_exists_abc',
telegramFileId: 'stored_file_id',
telegramFileUniqueId: 'doc_uniq_123',
});
const ctx = {
message: {
@@ -215,7 +230,7 @@ describe('Telegram Bot Handler', () => {
const { startBot } = await import('../src/bot');
await startBot();
const fileHandler = mockOn.mock.calls[0][1];
const fileHandler = getFileHandler();
const replyMock = mock(() => Promise.resolve());
const ctx = {
message: {
@@ -245,7 +260,7 @@ describe('Telegram Bot Handler', () => {
const { startBot } = await import('../src/bot');
await startBot();
const fileHandler = mockOn.mock.calls[0][1];
const fileHandler = getFileHandler();
const replyMock = mock(() => Promise.resolve());
const ctx = {
message: {
-1
View File
@@ -1,4 +1,3 @@
// @ts-nocheck
import { describe, expect, it } from 'bun:test';
import { db, files } from '../src/db/index';
import { files as schemaFiles } from '../src/db/schema';
+2 -3
View File
@@ -1,4 +1,3 @@
// @ts-nocheck
import { describe, expect, it } from 'bun:test';
import { config } from '../src/env';
@@ -16,12 +15,12 @@ describe('Environment Variables Validation', () => {
});
it('config.botToken should return BOT_TOKEN from process.env', () => {
expect(config.botToken).toBe(process.env.BOT_TOKEN);
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));
expect(config.storageChatId).toBe(parseInt(process.env.STORAGE_CHANNEL_ID || '0', 10));
});
it('config.port should default to 3000 when not specified', () => {
-1
View File
@@ -1,4 +1,3 @@
// @ts-nocheck
import { describe, expect, it } from 'bun:test';
import {
checkFileSize,
+71 -38
View File
@@ -1,23 +1,62 @@
// @ts-nocheck
import { afterAll, beforeEach, describe, expect, it, mock } from 'bun:test';
// Mock database layer
const mockSelect = mock(() => ({
from: mock(() => ({
where: mock(() => ({
limit: mock(() => Promise.resolve([])),
})),
})),
}));
type RequestWithParams = Request & {
params?: {
public_id?: string;
};
};
mock.module('../src/db/index', () => ({
db: {
select: mockSelect,
},
files: {
publicId: {
equals: (val) => ({ type: 'equals', value: val }),
},
type ErrorBody = {
error: string;
};
type FileInfoBody = {
public_id: string;
file_name: string;
mime_type: string;
size_bytes: number;
file_type: string;
uploader_id: number;
created_at: string;
};
type JsonBody = ErrorBody | FileInfoBody | Record<string, unknown>;
type MockFileRecord = Record<string, unknown>;
type MockSelectChain = {
from: () => {
where: () => {
limit: () => Promise<MockFileRecord[]>;
};
};
};
const requestWithPublicId = (url: string, publicId: string): RequestWithParams => {
const req = new Request(url) as RequestWithParams;
req.params = { public_id: publicId };
return req;
};
const responseJson = async <T extends JsonBody>(res: Response): Promise<T> => {
return (await res.json()) as T;
};
// Mock database layer
const emptySelectChain = (): MockSelectChain => ({
from: () => ({
where: () => ({
limit: () => Promise.resolve([]),
}),
}),
});
const mockSelect = mock(() => emptySelectChain());
mock.module('../src/db/files', () => ({
findFileByPublicId: async () => {
const chain = mockSelect();
return (await chain.from().where().limit())[0] || null;
},
}));
@@ -38,7 +77,8 @@ mock.module('../src/utils/rateLimit', () => ({
}));
describe('File Route Handlers', () => {
let handleFileRedirect: any, handleFileInfo: any;
let handleFileRedirect: typeof import('../src/routes/files').handleFileRedirect;
let handleFileInfo: typeof import('../src/routes/files').handleFileInfo;
beforeEach(async () => {
mockSelect.mockClear();
@@ -56,12 +96,11 @@ describe('File Route Handlers', () => {
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');
req.params = { public_id: 'test-id' };
const req = requestWithPublicId('http://localhost:3000/f/test-id', 'test-id');
const res = await handleFileRedirect(req);
expect(res.status).toBe(429);
const body = await res.json();
const body = await responseJson<ErrorBody>(res);
expect(body.error).toBe('Rate limit exceeded');
});
@@ -74,11 +113,10 @@ describe('File Route Handlers', () => {
}),
}));
const req = new Request('http://localhost:3000/f/missing-id');
req.params = { public_id: 'missing-id' };
const req = requestWithPublicId('http://localhost:3000/f/missing-id', 'missing-id');
const res = await handleFileRedirect(req);
expect(res.status).toBe(404);
const body = await res.json();
const body = await responseJson<ErrorBody>(res);
expect(body.error).toBe('File not found');
});
@@ -99,8 +137,7 @@ describe('File Route Handlers', () => {
}),
}));
const req = new Request('http://localhost:3000/f/test-id');
req.params = { public_id: 'test-id' };
const req = requestWithPublicId('http://localhost:3000/f/test-id', 'test-id');
const res = await handleFileRedirect(req);
expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe(
@@ -113,11 +150,10 @@ describe('File Route Handlers', () => {
throw new Error('DB Connection Error');
});
const req = new Request('http://localhost:3000/f/test-id');
req.params = { public_id: 'test-id' };
const req = requestWithPublicId('http://localhost:3000/f/test-id', 'test-id');
const res = await handleFileRedirect(req);
expect(res.status).toBe(500);
const body = await res.json();
const body = await responseJson<ErrorBody>(res);
expect(body.error).toBe('Server error');
});
});
@@ -132,11 +168,10 @@ describe('File Route Handlers', () => {
}),
}));
const req = new Request('http://localhost:3000/file/missing-id/info');
req.params = { public_id: 'missing-id' };
const req = requestWithPublicId('http://localhost:3000/file/missing-id/info', 'missing-id');
const res = await handleFileInfo(req);
expect(res.status).toBe(404);
const body = await res.json();
const body = await responseJson<ErrorBody>(res);
expect(body.error).toBe('File not found');
});
@@ -159,11 +194,10 @@ describe('File Route Handlers', () => {
}),
}));
const req = new Request('http://localhost:3000/file/test-id/info');
req.params = { public_id: 'test-id' };
const req = requestWithPublicId('http://localhost:3000/file/test-id/info', 'test-id');
const res = await handleFileInfo(req);
expect(res.status).toBe(200);
const body = await res.json();
const body = await responseJson<FileInfoBody>(res);
expect(body).toEqual({
public_id: 'test-id',
file_name: 'image.png',
@@ -180,11 +214,10 @@ describe('File Route Handlers', () => {
throw new Error('DB Connection Error');
});
const req = new Request('http://localhost:3000/file/test-id/info');
req.params = { public_id: 'test-id' };
const req = requestWithPublicId('http://localhost:3000/file/test-id/info', 'test-id');
const res = await handleFileInfo(req);
expect(res.status).toBe(500);
const body = await res.json();
const body = await responseJson<ErrorBody>(res);
expect(body.error).toBe('Server error');
});
});
+1 -2
View File
@@ -1,4 +1,3 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, mock } from 'bun:test';
// Mock database layer
@@ -11,7 +10,7 @@ mock.module('../src/db/index', () => ({
}));
describe('Health Route Handler', () => {
let handleHealth: any;
let handleHealth: typeof import('../src/routes/health').handleHealth;
beforeEach(async () => {
mockExecute.mockClear();
-1
View File
@@ -1,4 +1,3 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, spyOn } from 'bun:test';
import logger from '../src/utils/logger';
import { checkRateLimit, cleanupRateLimitCache } from '../src/utils/rateLimit';
+5 -1
View File
@@ -8,7 +8,11 @@ describe('Swagger Documentation Endpoints', () => {
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json');
const body = (await res.json()) as any;
const body = (await res.json()) as {
openapi: string;
info: { title: string };
paths: Record<string, { post?: { requestBody: { content: Record<string, unknown> } } }>;
};
expect(body.openapi).toBe('3.0.0');
expect(body.info.title).toBe('TeleUploader API');
expect(body.paths).toHaveProperty('/health');
+3 -2
View File
@@ -1,4 +1,3 @@
// @ts-nocheck
import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import { config } from '../src/env';
import logger from '../src/utils/logger';
@@ -57,7 +56,9 @@ const infoSpy = spyOn(logger, 'info');
const errorSpy = spyOn(logger, 'error');
describe('Telegram API Utilities', () => {
let forwardToStorage: any, getFileInfo: any, getBot: any;
let forwardToStorage: typeof import('../src/utils/telegram').forwardToStorage;
let getFileInfo: typeof import('../src/utils/telegram').getFileInfo;
let getBot: typeof import('../src/utils/telegram').getBot;
beforeEach(async () => {
infoSpy.mockClear();
+27 -9
View File
@@ -1,4 +1,3 @@
// @ts-nocheck
import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
let realPhotoBuffer: Buffer;
@@ -21,7 +20,26 @@ beforeAll(async () => {
});
// Mock db
let mockSelectResult: any[] = [];
type UploadResponseBody = {
public_id: string;
telegram_file_id: string;
telegram_file_unique_id: string;
file_name: string;
file_type: string;
download_url: string;
};
type ErrorResponseBody = {
error: string;
};
type UploadJsonBody = UploadResponseBody & Partial<ErrorResponseBody>;
let mockSelectResult: unknown[] = [];
const uploadResponseJson = async (res: Response): Promise<UploadJsonBody> => {
return (await res.json()) as UploadJsonBody;
};
const mockLimit = mock(() => Promise.resolve(mockSelectResult));
const mockWhere = mock(() => ({
@@ -79,7 +97,7 @@ mock.module('../src/utils/telegram', () => ({
}));
describe('Upload Route Handler', () => {
let handleUpload: any;
let handleUpload: typeof import('../src/routes/upload').handleUpload;
beforeEach(async () => {
mockInsert.mockClear();
@@ -105,7 +123,7 @@ describe('Upload Route Handler', () => {
const res = await handleUpload(req);
expect(res.status).toBe(400);
const body = await res.json();
const body = await uploadResponseJson(res);
expect(body.error).toContain('Unsupported content type');
});
@@ -123,7 +141,7 @@ describe('Upload Route Handler', () => {
const res = await handleUpload(req);
expect(res.status).toBe(200);
const body = await res.json();
const body = await uploadResponseJson(res);
expect(body.public_id).toContain('mocked-nanoid-id');
expect(body.telegram_file_id).toBe('tg-file-id-123');
@@ -145,7 +163,7 @@ describe('Upload Route Handler', () => {
const res = await handleUpload(req);
expect(res.status).toBe(400);
const body = await res.json();
const body = await uploadResponseJson(res);
expect(body.error).toContain('Invalid JSON');
});
@@ -161,7 +179,7 @@ describe('Upload Route Handler', () => {
const res = await handleUpload(req);
expect(res.status).toBe(200);
const body = await res.json();
const body = await uploadResponseJson(res);
expect(body.public_id).toContain('mocked-nanoid-id');
expect(body.file_name).toBe('test_multi.png');
});
@@ -195,7 +213,7 @@ describe('Upload Route Handler', () => {
const res = await handleUpload(req);
expect(res.status).toBe(200);
const body = await res.json();
const body = await uploadResponseJson(res);
expect(body.public_id).toBe('existing-id-123');
expect(body.telegram_file_id).toBe('existing-tg-id');
@@ -242,7 +260,7 @@ describe('Upload Route Handler', () => {
const res = await handleUpload(req);
expect(res.status).toBe(200);
const body = await res.json();
const body = await uploadResponseJson(res);
expect(body.public_id).toBe('existing-json-id');
expect(body.telegram_file_id).toBe('existing-tg-json-id');