feat: enhance file upload handling with improved file detection, size limits, and response formatting
This commit is contained in:
+49
-67
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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,4 +1,3 @@
|
||||
// @ts-nocheck
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
checkFileSize,
|
||||
|
||||
+71
-38
@@ -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
@@ -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,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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user