diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..1381863 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,3 @@ +import { config } from '../env'; + +export { config }; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 9647305..9f00f84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,14 @@ import { serve } from 'bun'; -import { startBot } from './bot'; -import { config } from './env'; -import { handleLogin, handleLogout, handleMe } from './routes/auth'; -import { handleFileInfo, handleFileRedirect } from './routes/files'; -import { handleHealth } from './routes/health'; -import { handleHome } from './routes/home'; -import { handleS3Request } from './routes/s3'; -import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger'; -import { handleUpload } from './routes/upload'; -import { handleWebApiV1 } from './routes/web-api'; -import { requireAuth } from './utils/auth'; -import { fileInfoCache } from './utils/cache'; -import logger from './utils/logger'; -import { metricsCollector } from './utils/metrics'; -import { cleanupRateLimitCache, withRateLimit } from './utils/rateLimit'; -import { isS3Request } from './utils/s3/auth'; -import { extractS3BucketFromHost } from './utils/s3/virtual-host'; +import { config } from './config/index'; +import { startBot } from './interfaces/bot/handler'; +import { routes } from './interfaces/http/routes/index'; +import { isS3Request } from './interfaces/s3/auth'; +import { handleS3Request } from './interfaces/http/controllers/s3-controller'; +import { extractS3BucketFromHost } from './interfaces/s3/virtual-host'; +import { fileInfoCache } from './infrastructure/cache/index'; +import { cleanupRateLimitCache } from './interfaces/http/middleware/rate-limit'; +import { logger } from './shared/logger/index'; +import { metricsCollector } from './shared/metrics/index'; // ─── Auto-run migration at startup ────────────────────────────────────────── try { @@ -58,55 +51,7 @@ const handleMaybeS3Root = (req: Request): Response | Promise => { const server = serve({ port: config.port, - routes: { - '/api/upload': { - POST: withRateLimit(handleUpload), - }, - '/f/:public_id': { - GET: withRateLimit(handleFileRedirect), - }, - '/file/:public_id/info': { - GET: withRateLimit(handleFileInfo), - }, - '/health': { - GET: handleHealth, - }, - '/docs': { - GET: handleSwaggerHtml, - }, - '/swagger.json': { - GET: handleSwaggerJson, - }, - '/': { - GET: (req: Request) => { - const headers = Object.fromEntries(req.headers); - if (shouldHandleS3(req, headers)) { - return handleS3Request(req, getS3RouteBucket(req)); - } - return handleHome(); - }, - PUT: handleMaybeS3Root, - HEAD: handleMaybeS3Root, - DELETE: handleMaybeS3Root, - POST: handleMaybeS3Root, - OPTIONS: handleMaybeS3Root, - }, - '/api/v1/auth/login': { - POST: withRateLimit(handleLogin), - }, - '/api/v1/auth/logout': { - POST: handleLogout, - }, - '/api/v1/auth/me': { - GET: handleMe, - }, - '/api/v1/*': { - GET: requireAuth(handleWebApiV1), - POST: requireAuth(handleWebApiV1), - DELETE: requireAuth(handleWebApiV1), - PUT: requireAuth(handleWebApiV1), - }, - }, + routes, fetch: async (req: Request) => { if (req.method === 'OPTIONS') { return handleS3Request(req, getS3RouteBucket(req)); @@ -163,4 +108,4 @@ setInterval( 5 * 60 * 1000, ); -logger.info('Application running successfully'); +logger.info('Application running successfully'); \ No newline at end of file diff --git a/src/infrastructure/cache/index.ts b/src/infrastructure/cache/index.ts new file mode 100644 index 0000000..5dcf929 --- /dev/null +++ b/src/infrastructure/cache/index.ts @@ -0,0 +1 @@ +export { fileInfoCache, Cache } from '../../utils/cache'; \ No newline at end of file diff --git a/src/bot.ts b/src/interfaces/bot/handler.ts similarity index 51% rename from src/bot.ts rename to src/interfaces/bot/handler.ts index ac1116a..cdaa252 100644 --- a/src/bot.ts +++ b/src/interfaces/bot/handler.ts @@ -1,29 +1,62 @@ 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 { config } from '../../env'; +import type { NewFile } from '../../domain/entities/file'; +import type { IFileRepository } from '../../domain/ports/file-repository'; +import type { ITelegramService } from '../../domain/ports/telegram-service'; +import { DrizzleFileRepository } from '../../infrastructure/persistence/repositories/file-repository'; +import { botPool } from '../../infrastructure/telegram/bot-pool'; import { detectFileType, extractFileFromMessage, getErrorMessage, getFileSizeLimit, type TelegramMediaMessage, -} from './utils/file'; -import logger from './utils/logger'; -import { forwardToStorage } from './utils/telegram'; +} from '../../shared/utils/file'; +import logger from '../../utils/logger'; +/** + * Minimal bot context shape used by the media event handler. + * + * Represents the subset of Telegraf's Context that the handler requires + * for processing incoming media messages. + */ type BotContext = { + /** The incoming media message with file attachments. */ message: TelegramMediaMessage; + /** The sender of the message. */ from: { id: number }; + /** The chat where the message was sent, if available. */ chat?: { id: number }; + /** + * Reply to the message with text. + * + * @param text - The reply text. + * @param extra - Optional reply parameters (e.g. reply_parameters for threading). + */ reply: (text: string, extra?: { reply_parameters: { message_id: number } }) => Promise; }; +/** + * Duck-typed object that exposes a Telegraf-style `on()` method + * for registering event handlers on multiple event types. + */ type MediaEventRegistrar = { + /** + * Register a handler for the given event types. + * + * @param events - Array of event type strings (e.g. "document", "photo"). + * @param handler - Async handler receiving the bot context. + */ on: (events: string[], handler: (ctx: BotContext) => Promise) => void; }; +/** + * Replies to a Telegram message with a download URL for the uploaded file. + * + * @param ctx - The bot context for the incoming message. + * @param publicId - The public identifier of the uploaded file. + */ const replyWithDownloadUrl = async (ctx: BotContext, publicId: string): Promise => { const url = `${config.baseUrl}/f/${publicId}`; await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, { @@ -31,7 +64,33 @@ const replyWithDownloadUrl = async (ctx: BotContext, publicId: string): Promise< }); }; -export const startBot = async (): Promise> => { +/** + * Start the Telegram bot and register message handlers. + * + * Creates a Telegraf instance, registers a `/start` command handler, + * logging middleware, and media event handlers for all supported file types. + * Incoming media files are deduplicated by their Telegram unique ID, + * forwarded to the storage channel, and persisted with a public download URL. + * + * @param deps - Optional external dependencies for testing or DI override. + * @param deps.telegramService - The Telegram service used to forward files to + * the storage channel. Defaults to the singleton BotPool instance. + * @param deps.fileRepo - The file repository used for deduplication queries + * and persisting new file records. Defaults to a new DrizzleFileRepository. + * @returns The launched Telegraf bot instance, suitable for graceful shutdown + * via `bot.stop(signal)`. + */ +export async function startBot( + deps: { + /** The Telegram service to forward files to storage. */ + telegramService?: ITelegramService; + /** The file repository for deduplication and persistence. */ + fileRepo?: IFileRepository; + } = {}, +): Promise> { + const telegramService = deps.telegramService ?? botPool; + const fileRepo = deps.fileRepo ?? new DrizzleFileRepository(); + try { const bot = new Telegraf(config.botToken); @@ -74,7 +133,7 @@ export const startBot = async (): Promise> => { return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`); } - const existing = await findFileByUniqueId(fileObj.file_unique_id); + const existing = await fileRepo.findByUniqueId(fileObj.file_unique_id); if (existing) { await replyWithDownloadUrl(ctx, existing.publicId); @@ -87,25 +146,36 @@ export const startBot = async (): Promise> => { return; } - const result = await forwardToStorage(file_id, fileName, fileType); + const result = await telegramService.forwardToStorage(file_id, fileName, fileType); const publicId = nanoid(); - const uploaded = { - publicId: publicId, + const uploaded: NewFile = { + publicId, telegramFileId: result.telegramFileId, telegramFileUniqueId: result.telegramFileUniqueId, storageChatId: config.storageChatId, storageMessageId: result.storageMessageId, - fileName: fileName, + fileName, mimeType: mime_type || 'application/octet-stream', sizeBytes: fileSize, - fileType: fileType, + fileType, uploaderId: ctx.from.id, - createdAt: new Date(), - updatedAt: new Date(), + fileHash: null, + archiveTelegramFileId: null, + archiveStorageMessageId: null, + archiveFileName: null, + archiveEntryName: null, + archiveMimeType: null, + archiveSizeBytes: null, + bucketId: null, + s3Key: null, + storageBackend: 'telegram', + isDeleted: false, + multipartUploadId: null, + partCount: null, }; - await db.insert(fileSchema).values(uploaded); + await fileRepo.create(uploaded); await replyWithDownloadUrl(ctx, publicId); @@ -134,4 +204,4 @@ export const startBot = async (): Promise> => { logger.error('Failed to start bot', { error: getErrorMessage(error) }); throw error; } -}; +} diff --git a/src/interfaces/http/middleware/rate-limit.ts b/src/interfaces/http/middleware/rate-limit.ts new file mode 100644 index 0000000..790ea39 --- /dev/null +++ b/src/interfaces/http/middleware/rate-limit.ts @@ -0,0 +1 @@ +export { withRateLimit, cleanupRateLimitCache, checkRateLimit, clearRateLimitCache, getRateLimitStats } from '../../../utils/rateLimit'; \ No newline at end of file diff --git a/src/interfaces/s3/auth.ts b/src/interfaces/s3/auth.ts new file mode 100644 index 0000000..6a14c0f --- /dev/null +++ b/src/interfaces/s3/auth.ts @@ -0,0 +1,2 @@ +export { isS3Request, buildCanonicalQueryString, verifyPresignedUrl, verifySignature } from '../../utils/s3/auth'; +export type { SigV4Result, VerifyPresignedUrlInput } from '../../utils/s3/auth'; \ No newline at end of file diff --git a/src/interfaces/s3/virtual-host.ts b/src/interfaces/s3/virtual-host.ts new file mode 100644 index 0000000..d0ed1f1 --- /dev/null +++ b/src/interfaces/s3/virtual-host.ts @@ -0,0 +1 @@ +export { extractS3BucketFromHost } from '../../utils/s3/virtual-host'; \ No newline at end of file diff --git a/src/shared/logger/index.ts b/src/shared/logger/index.ts new file mode 100644 index 0000000..a657f2b --- /dev/null +++ b/src/shared/logger/index.ts @@ -0,0 +1,3 @@ +import logger from '../../utils/logger'; + +export { logger }; \ No newline at end of file diff --git a/src/shared/metrics/index.ts b/src/shared/metrics/index.ts new file mode 100644 index 0000000..8905300 --- /dev/null +++ b/src/shared/metrics/index.ts @@ -0,0 +1 @@ +export { metricsCollector, MetricsCollector } from '../../utils/metrics'; \ No newline at end of file diff --git a/test/bootstrap.test.ts b/test/bootstrap.test.ts index 08aa332..9a54266 100644 --- a/test/bootstrap.test.ts +++ b/test/bootstrap.test.ts @@ -36,7 +36,7 @@ const mockRequireAuth = mock( Response.json({ error: 'Unauthorized' }, { status: 401 }), ); -mock.module('../src/bot', () => ({ +mock.module('../src/interfaces/bot/handler', () => ({ startBot: mockStartBot, })); @@ -65,6 +65,9 @@ mock.module('../src/routes/auth', () => ({ mock.module('../src/utils/rateLimit', () => ({ cleanupRateLimitCache: mock(), + clearRateLimitCache: mock(), + checkRateLimit: mock(() => true), + getRateLimitStats: mock(() => ({})), withRateLimit: ( handler: (req: T) => Promise, ): ((req: T) => Promise) => handler, diff --git a/test/bot.test.ts b/test/bot.test.ts index 8382f03..36045e3 100644 --- a/test/bot.test.ts +++ b/test/bot.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; -import type { TelegramMediaMessage } from '../src/utils/file'; +import type { TelegramMediaMessage } from '../src/shared/utils/file'; import logger from '../src/utils/logger'; // Mock environment @@ -46,61 +46,43 @@ mock.module('telegraf', () => ({ Telegraf: MockTelegraf, })); -// Mock database -const mockInsert = mock(() => ({ - values: mock(() => Promise.resolve()), -})); -type ExistingFile = { - publicId: string; - telegramFileId: string; - telegramFileUniqueId: string; -}; - -const mockFindFileByUniqueId = mock((): Promise => Promise.resolve(null)); - -mock.module('../src/db/index', () => ({ - db: { - insert: mockInsert, - }, - files: {}, -})); - -mock.module('../src/db/files', () => ({ - findFileByUniqueId: mockFindFileByUniqueId, -})); - -// Mock forwardToStorage -const mockForwardToStorage = mock(() => - Promise.resolve({ - telegramFileId: 'stored_file_id', - telegramFileUniqueId: 'stored_unique_id', - storageMessageId: 9999, - }), -); -mock.module('../src/utils/telegram', () => ({ - forwardToStorage: mockForwardToStorage, -})); - const infoSpy = spyOn(logger, 'info'); const errorSpy = spyOn(logger, 'error'); describe('Telegram Bot Handler', () => { + let mockTelegramService: { forwardToStorage: ReturnType }; + let mockFileRepo: { findByUniqueId: ReturnType; create: ReturnType }; + beforeEach(() => { mockLaunch.mockClear(); mockCommand.mockClear(); mockOn.mockClear(); mockUse.mockClear(); - mockInsert.mockClear(); - mockFindFileByUniqueId.mockClear(); - mockFindFileByUniqueId.mockResolvedValue(null); - mockForwardToStorage.mockClear(); infoSpy.mockClear(); errorSpy.mockClear(); + + mockTelegramService = { + forwardToStorage: mock(() => + Promise.resolve({ + telegramFileId: 'stored_file_id', + telegramFileUniqueId: 'stored_unique_id', + storageMessageId: 9999, + }), + ), + }; + + mockFileRepo = { + findByUniqueId: mock((): Promise => Promise.resolve(null)), + create: mock(() => Promise.resolve()), + }; }); it('should initialize and launch the bot', async () => { - const { startBot } = await import('../src/bot'); - const bot = await startBot(); + const { startBot } = await import('../src/interfaces/bot/handler'); + const bot = await startBot({ + telegramService: mockTelegramService, + fileRepo: mockFileRepo, + }); expect(bot).toBeDefined(); expect(mockCommand).toHaveBeenCalledWith('start', expect.any(Function)); @@ -113,8 +95,11 @@ describe('Telegram Bot Handler', () => { }); it('should handle /start command', async () => { - const { startBot } = await import('../src/bot'); - await startBot(); + const { startBot } = await import('../src/interfaces/bot/handler'); + await startBot({ + telegramService: mockTelegramService, + fileRepo: mockFileRepo, + }); const startHandler = getStartHandler(); const replyMock = mock(() => Promise.resolve()); @@ -127,8 +112,11 @@ describe('Telegram Bot Handler', () => { }); it('should process document uploads and save to db', async () => { - const { startBot } = await import('../src/bot'); - await startBot(); + const { startBot } = await import('../src/interfaces/bot/handler'); + await startBot({ + telegramService: mockTelegramService, + fileRepo: mockFileRepo, + }); const fileHandler = getFileHandler(); const replyMock = mock(() => Promise.resolve()); @@ -150,8 +138,8 @@ describe('Telegram Bot Handler', () => { }; await fileHandler(ctx); - expect(mockForwardToStorage).toHaveBeenCalledWith('doc_123', 'cv.pdf', 'document'); - expect(mockInsert).toHaveBeenCalled(); + expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith('doc_123', 'cv.pdf', 'document'); + expect(mockFileRepo.create).toHaveBeenCalled(); expect(replyMock).toHaveBeenCalledWith( expect.stringContaining('File berhasil diupload'), expect.any(Object), @@ -159,8 +147,11 @@ describe('Telegram Bot Handler', () => { }); it('should reject uploads exceeding max size limit', async () => { - const { startBot } = await import('../src/bot'); - await startBot(); + const { startBot } = await import('../src/interfaces/bot/handler'); + await startBot({ + telegramService: mockTelegramService, + fileRepo: mockFileRepo, + }); const fileHandler = getFileHandler(); const replyMock = mock(() => Promise.resolve()); @@ -183,18 +174,21 @@ describe('Telegram Bot Handler', () => { }; await fileHandler(ctx); - expect(mockForwardToStorage).not.toHaveBeenCalled(); + expect(mockTelegramService.forwardToStorage).not.toHaveBeenCalled(); expect(replyMock).toHaveBeenCalledWith(expect.stringContaining('exceeds')); }); it('should return existing download link for duplicates without uploading again', async () => { - const { startBot } = await import('../src/bot'); - await startBot(); + const { startBot } = await import('../src/interfaces/bot/handler'); + await startBot({ + telegramService: mockTelegramService, + fileRepo: mockFileRepo, + }); const fileHandler = getFileHandler(); const replyMock = mock(() => Promise.resolve()); - mockFindFileByUniqueId.mockResolvedValueOnce({ + mockFileRepo.findByUniqueId.mockResolvedValueOnce({ publicId: 'already_exists_abc', telegramFileId: 'stored_file_id', telegramFileUniqueId: 'doc_uniq_123', @@ -218,8 +212,8 @@ describe('Telegram Bot Handler', () => { }; await fileHandler(ctx); - expect(mockForwardToStorage).not.toHaveBeenCalled(); - expect(mockInsert).not.toHaveBeenCalled(); + expect(mockTelegramService.forwardToStorage).not.toHaveBeenCalled(); + expect(mockFileRepo.create).not.toHaveBeenCalled(); expect(replyMock).toHaveBeenCalledWith( expect.stringContaining('already_exists_abc'), expect.any(Object), @@ -227,8 +221,11 @@ describe('Telegram Bot Handler', () => { }); it('should process sticker uploads', async () => { - const { startBot } = await import('../src/bot'); - await startBot(); + const { startBot } = await import('../src/interfaces/bot/handler'); + await startBot({ + telegramService: mockTelegramService, + fileRepo: mockFileRepo, + }); const fileHandler = getFileHandler(); const replyMock = mock(() => Promise.resolve()); @@ -248,8 +245,8 @@ describe('Telegram Bot Handler', () => { }; await fileHandler(ctx); - expect(mockForwardToStorage).toHaveBeenCalledWith('sticker_123', 'file', 'sticker'); - expect(mockInsert).toHaveBeenCalled(); + expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith('sticker_123', 'file', 'sticker'); + expect(mockFileRepo.create).toHaveBeenCalled(); expect(replyMock).toHaveBeenCalledWith( expect.stringContaining('File berhasil diupload'), expect.any(Object), @@ -257,8 +254,11 @@ describe('Telegram Bot Handler', () => { }); it('should process video note uploads', async () => { - const { startBot } = await import('../src/bot'); - await startBot(); + const { startBot } = await import('../src/interfaces/bot/handler'); + await startBot({ + telegramService: mockTelegramService, + fileRepo: mockFileRepo, + }); const fileHandler = getFileHandler(); const replyMock = mock(() => Promise.resolve()); @@ -278,8 +278,8 @@ describe('Telegram Bot Handler', () => { }; await fileHandler(ctx); - expect(mockForwardToStorage).toHaveBeenCalledWith('video_note_123', 'file', 'video_note'); - expect(mockInsert).toHaveBeenCalled(); + expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith('video_note_123', 'file', 'video_note'); + expect(mockFileRepo.create).toHaveBeenCalled(); expect(replyMock).toHaveBeenCalledWith( expect.stringContaining('File berhasil diupload'), expect.any(Object), @@ -289,4 +289,4 @@ describe('Telegram Bot Handler', () => { afterAll(() => { mock.restore(); }); -}); +}); \ No newline at end of file