feat: rewire entry point to new architecture

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-28 18:12:48 +07:00
parent ea48e8fc03
commit 234ca7b14c
11 changed files with 180 additions and 150 deletions
+3
View File
@@ -0,0 +1,3 @@
import { config } from '../env';
export { config };
+12 -67
View File
@@ -1,21 +1,14 @@
import { serve } from 'bun'; import { serve } from 'bun';
import { startBot } from './bot'; import { config } from './config/index';
import { config } from './env'; import { startBot } from './interfaces/bot/handler';
import { handleLogin, handleLogout, handleMe } from './routes/auth'; import { routes } from './interfaces/http/routes/index';
import { handleFileInfo, handleFileRedirect } from './routes/files'; import { isS3Request } from './interfaces/s3/auth';
import { handleHealth } from './routes/health'; import { handleS3Request } from './interfaces/http/controllers/s3-controller';
import { handleHome } from './routes/home'; import { extractS3BucketFromHost } from './interfaces/s3/virtual-host';
import { handleS3Request } from './routes/s3'; import { fileInfoCache } from './infrastructure/cache/index';
import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger'; import { cleanupRateLimitCache } from './interfaces/http/middleware/rate-limit';
import { handleUpload } from './routes/upload'; import { logger } from './shared/logger/index';
import { handleWebApiV1 } from './routes/web-api'; import { metricsCollector } from './shared/metrics/index';
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';
// ─── Auto-run migration at startup ────────────────────────────────────────── // ─── Auto-run migration at startup ──────────────────────────────────────────
try { try {
@@ -58,55 +51,7 @@ const handleMaybeS3Root = (req: Request): Response | Promise<Response> => {
const server = serve({ const server = serve({
port: config.port, port: config.port,
routes: { 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),
},
},
fetch: async (req: Request) => { fetch: async (req: Request) => {
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
return handleS3Request(req, getS3RouteBucket(req)); return handleS3Request(req, getS3RouteBucket(req));
@@ -163,4 +108,4 @@ setInterval(
5 * 60 * 1000, 5 * 60 * 1000,
); );
logger.info('Application running successfully'); logger.info('Application running successfully');
+1
View File
@@ -0,0 +1 @@
export { fileInfoCache, Cache } from '../../utils/cache';
+87 -17
View File
@@ -1,29 +1,62 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { type Context, Telegraf } from 'telegraf'; import { type Context, Telegraf } from 'telegraf';
import { db, files as fileSchema } from './db'; import { config } from '../../env';
import { findFileByUniqueId } from './db/files'; import type { NewFile } from '../../domain/entities/file';
import { config } from './env'; 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 { import {
detectFileType, detectFileType,
extractFileFromMessage, extractFileFromMessage,
getErrorMessage, getErrorMessage,
getFileSizeLimit, getFileSizeLimit,
type TelegramMediaMessage, type TelegramMediaMessage,
} from './utils/file'; } from '../../shared/utils/file';
import logger from './utils/logger'; import logger from '../../utils/logger';
import { forwardToStorage } from './utils/telegram';
/**
* 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 = { type BotContext = {
/** The incoming media message with file attachments. */
message: TelegramMediaMessage; message: TelegramMediaMessage;
/** The sender of the message. */
from: { id: number }; from: { id: number };
/** The chat where the message was sent, if available. */
chat?: { id: number }; 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<unknown>; reply: (text: string, extra?: { reply_parameters: { message_id: number } }) => Promise<unknown>;
}; };
/**
* Duck-typed object that exposes a Telegraf-style `on()` method
* for registering event handlers on multiple event types.
*/
type MediaEventRegistrar = { 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<unknown>) => void; on: (events: string[], handler: (ctx: BotContext) => Promise<unknown>) => 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<void> => { const replyWithDownloadUrl = async (ctx: BotContext, publicId: string): Promise<void> => {
const url = `${config.baseUrl}/f/${publicId}`; const url = `${config.baseUrl}/f/${publicId}`;
await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, { 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<Telegraf<Context>> => { /**
* 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<Telegraf<Context>> {
const telegramService = deps.telegramService ?? botPool;
const fileRepo = deps.fileRepo ?? new DrizzleFileRepository();
try { try {
const bot = new Telegraf(config.botToken); const bot = new Telegraf(config.botToken);
@@ -74,7 +133,7 @@ export const startBot = async (): Promise<Telegraf<Context>> => {
return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`); 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) { if (existing) {
await replyWithDownloadUrl(ctx, existing.publicId); await replyWithDownloadUrl(ctx, existing.publicId);
@@ -87,25 +146,36 @@ export const startBot = async (): Promise<Telegraf<Context>> => {
return; return;
} }
const result = await forwardToStorage(file_id, fileName, fileType); const result = await telegramService.forwardToStorage(file_id, fileName, fileType);
const publicId = nanoid(); const publicId = nanoid();
const uploaded = { const uploaded: NewFile = {
publicId: publicId, publicId,
telegramFileId: result.telegramFileId, telegramFileId: result.telegramFileId,
telegramFileUniqueId: result.telegramFileUniqueId, telegramFileUniqueId: result.telegramFileUniqueId,
storageChatId: config.storageChatId, storageChatId: config.storageChatId,
storageMessageId: result.storageMessageId, storageMessageId: result.storageMessageId,
fileName: fileName, fileName,
mimeType: mime_type || 'application/octet-stream', mimeType: mime_type || 'application/octet-stream',
sizeBytes: fileSize, sizeBytes: fileSize,
fileType: fileType, fileType,
uploaderId: ctx.from.id, uploaderId: ctx.from.id,
createdAt: new Date(), fileHash: null,
updatedAt: new Date(), 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); await replyWithDownloadUrl(ctx, publicId);
@@ -134,4 +204,4 @@ export const startBot = async (): Promise<Telegraf<Context>> => {
logger.error('Failed to start bot', { error: getErrorMessage(error) }); logger.error('Failed to start bot', { error: getErrorMessage(error) });
throw error; throw error;
} }
}; }
@@ -0,0 +1 @@
export { withRateLimit, cleanupRateLimitCache, checkRateLimit, clearRateLimitCache, getRateLimitStats } from '../../../utils/rateLimit';
+2
View File
@@ -0,0 +1,2 @@
export { isS3Request, buildCanonicalQueryString, verifyPresignedUrl, verifySignature } from '../../utils/s3/auth';
export type { SigV4Result, VerifyPresignedUrlInput } from '../../utils/s3/auth';
+1
View File
@@ -0,0 +1 @@
export { extractS3BucketFromHost } from '../../utils/s3/virtual-host';
+3
View File
@@ -0,0 +1,3 @@
import logger from '../../utils/logger';
export { logger };
+1
View File
@@ -0,0 +1 @@
export { metricsCollector, MetricsCollector } from '../../utils/metrics';
+4 -1
View File
@@ -36,7 +36,7 @@ const mockRequireAuth = mock(
Response.json({ error: 'Unauthorized' }, { status: 401 }), Response.json({ error: 'Unauthorized' }, { status: 401 }),
); );
mock.module('../src/bot', () => ({ mock.module('../src/interfaces/bot/handler', () => ({
startBot: mockStartBot, startBot: mockStartBot,
})); }));
@@ -65,6 +65,9 @@ mock.module('../src/routes/auth', () => ({
mock.module('../src/utils/rateLimit', () => ({ mock.module('../src/utils/rateLimit', () => ({
cleanupRateLimitCache: mock(), cleanupRateLimitCache: mock(),
clearRateLimitCache: mock(),
checkRateLimit: mock(() => true),
getRateLimitStats: mock(() => ({})),
withRateLimit: <T extends Request>( withRateLimit: <T extends Request>(
handler: (req: T) => Promise<Response>, handler: (req: T) => Promise<Response>,
): ((req: T) => Promise<Response>) => handler, ): ((req: T) => Promise<Response>) => handler,
+65 -65
View File
@@ -1,5 +1,5 @@
import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; 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'; import logger from '../src/utils/logger';
// Mock environment // Mock environment
@@ -46,61 +46,43 @@ mock.module('telegraf', () => ({
Telegraf: MockTelegraf, Telegraf: MockTelegraf,
})); }));
// Mock database
const mockInsert = mock(() => ({
values: mock(() => Promise.resolve()),
}));
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,
},
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 infoSpy = spyOn(logger, 'info');
const errorSpy = spyOn(logger, 'error'); const errorSpy = spyOn(logger, 'error');
describe('Telegram Bot Handler', () => { describe('Telegram Bot Handler', () => {
let mockTelegramService: { forwardToStorage: ReturnType<typeof mock> };
let mockFileRepo: { findByUniqueId: ReturnType<typeof mock>; create: ReturnType<typeof mock> };
beforeEach(() => { beforeEach(() => {
mockLaunch.mockClear(); mockLaunch.mockClear();
mockCommand.mockClear(); mockCommand.mockClear();
mockOn.mockClear(); mockOn.mockClear();
mockUse.mockClear(); mockUse.mockClear();
mockInsert.mockClear();
mockFindFileByUniqueId.mockClear();
mockFindFileByUniqueId.mockResolvedValue(null);
mockForwardToStorage.mockClear();
infoSpy.mockClear(); infoSpy.mockClear();
errorSpy.mockClear(); errorSpy.mockClear();
mockTelegramService = {
forwardToStorage: mock(() =>
Promise.resolve({
telegramFileId: 'stored_file_id',
telegramFileUniqueId: 'stored_unique_id',
storageMessageId: 9999,
}),
),
};
mockFileRepo = {
findByUniqueId: mock((): Promise<unknown> => Promise.resolve(null)),
create: mock(() => Promise.resolve()),
};
}); });
it('should initialize and launch the bot', async () => { it('should initialize and launch the bot', async () => {
const { startBot } = await import('../src/bot'); const { startBot } = await import('../src/interfaces/bot/handler');
const bot = await startBot(); const bot = await startBot({
telegramService: mockTelegramService,
fileRepo: mockFileRepo,
});
expect(bot).toBeDefined(); expect(bot).toBeDefined();
expect(mockCommand).toHaveBeenCalledWith('start', expect.any(Function)); expect(mockCommand).toHaveBeenCalledWith('start', expect.any(Function));
@@ -113,8 +95,11 @@ describe('Telegram Bot Handler', () => {
}); });
it('should handle /start command', async () => { it('should handle /start command', async () => {
const { startBot } = await import('../src/bot'); const { startBot } = await import('../src/interfaces/bot/handler');
await startBot(); await startBot({
telegramService: mockTelegramService,
fileRepo: mockFileRepo,
});
const startHandler = getStartHandler(); const startHandler = getStartHandler();
const replyMock = mock(() => Promise.resolve()); const replyMock = mock(() => Promise.resolve());
@@ -127,8 +112,11 @@ describe('Telegram Bot Handler', () => {
}); });
it('should process document uploads and save to db', async () => { it('should process document uploads and save to db', async () => {
const { startBot } = await import('../src/bot'); const { startBot } = await import('../src/interfaces/bot/handler');
await startBot(); await startBot({
telegramService: mockTelegramService,
fileRepo: mockFileRepo,
});
const fileHandler = getFileHandler(); const fileHandler = getFileHandler();
const replyMock = mock(() => Promise.resolve()); const replyMock = mock(() => Promise.resolve());
@@ -150,8 +138,8 @@ describe('Telegram Bot Handler', () => {
}; };
await fileHandler(ctx); await fileHandler(ctx);
expect(mockForwardToStorage).toHaveBeenCalledWith('doc_123', 'cv.pdf', 'document'); expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith('doc_123', 'cv.pdf', 'document');
expect(mockInsert).toHaveBeenCalled(); expect(mockFileRepo.create).toHaveBeenCalled();
expect(replyMock).toHaveBeenCalledWith( expect(replyMock).toHaveBeenCalledWith(
expect.stringContaining('File berhasil diupload'), expect.stringContaining('File berhasil diupload'),
expect.any(Object), expect.any(Object),
@@ -159,8 +147,11 @@ describe('Telegram Bot Handler', () => {
}); });
it('should reject uploads exceeding max size limit', async () => { it('should reject uploads exceeding max size limit', async () => {
const { startBot } = await import('../src/bot'); const { startBot } = await import('../src/interfaces/bot/handler');
await startBot(); await startBot({
telegramService: mockTelegramService,
fileRepo: mockFileRepo,
});
const fileHandler = getFileHandler(); const fileHandler = getFileHandler();
const replyMock = mock(() => Promise.resolve()); const replyMock = mock(() => Promise.resolve());
@@ -183,18 +174,21 @@ describe('Telegram Bot Handler', () => {
}; };
await fileHandler(ctx); await fileHandler(ctx);
expect(mockForwardToStorage).not.toHaveBeenCalled(); expect(mockTelegramService.forwardToStorage).not.toHaveBeenCalled();
expect(replyMock).toHaveBeenCalledWith(expect.stringContaining('exceeds')); expect(replyMock).toHaveBeenCalledWith(expect.stringContaining('exceeds'));
}); });
it('should return existing download link for duplicates without uploading again', async () => { it('should return existing download link for duplicates without uploading again', async () => {
const { startBot } = await import('../src/bot'); const { startBot } = await import('../src/interfaces/bot/handler');
await startBot(); await startBot({
telegramService: mockTelegramService,
fileRepo: mockFileRepo,
});
const fileHandler = getFileHandler(); const fileHandler = getFileHandler();
const replyMock = mock(() => Promise.resolve()); const replyMock = mock(() => Promise.resolve());
mockFindFileByUniqueId.mockResolvedValueOnce({ mockFileRepo.findByUniqueId.mockResolvedValueOnce({
publicId: 'already_exists_abc', publicId: 'already_exists_abc',
telegramFileId: 'stored_file_id', telegramFileId: 'stored_file_id',
telegramFileUniqueId: 'doc_uniq_123', telegramFileUniqueId: 'doc_uniq_123',
@@ -218,8 +212,8 @@ describe('Telegram Bot Handler', () => {
}; };
await fileHandler(ctx); await fileHandler(ctx);
expect(mockForwardToStorage).not.toHaveBeenCalled(); expect(mockTelegramService.forwardToStorage).not.toHaveBeenCalled();
expect(mockInsert).not.toHaveBeenCalled(); expect(mockFileRepo.create).not.toHaveBeenCalled();
expect(replyMock).toHaveBeenCalledWith( expect(replyMock).toHaveBeenCalledWith(
expect.stringContaining('already_exists_abc'), expect.stringContaining('already_exists_abc'),
expect.any(Object), expect.any(Object),
@@ -227,8 +221,11 @@ describe('Telegram Bot Handler', () => {
}); });
it('should process sticker uploads', async () => { it('should process sticker uploads', async () => {
const { startBot } = await import('../src/bot'); const { startBot } = await import('../src/interfaces/bot/handler');
await startBot(); await startBot({
telegramService: mockTelegramService,
fileRepo: mockFileRepo,
});
const fileHandler = getFileHandler(); const fileHandler = getFileHandler();
const replyMock = mock(() => Promise.resolve()); const replyMock = mock(() => Promise.resolve());
@@ -248,8 +245,8 @@ describe('Telegram Bot Handler', () => {
}; };
await fileHandler(ctx); await fileHandler(ctx);
expect(mockForwardToStorage).toHaveBeenCalledWith('sticker_123', 'file', 'sticker'); expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith('sticker_123', 'file', 'sticker');
expect(mockInsert).toHaveBeenCalled(); expect(mockFileRepo.create).toHaveBeenCalled();
expect(replyMock).toHaveBeenCalledWith( expect(replyMock).toHaveBeenCalledWith(
expect.stringContaining('File berhasil diupload'), expect.stringContaining('File berhasil diupload'),
expect.any(Object), expect.any(Object),
@@ -257,8 +254,11 @@ describe('Telegram Bot Handler', () => {
}); });
it('should process video note uploads', async () => { it('should process video note uploads', async () => {
const { startBot } = await import('../src/bot'); const { startBot } = await import('../src/interfaces/bot/handler');
await startBot(); await startBot({
telegramService: mockTelegramService,
fileRepo: mockFileRepo,
});
const fileHandler = getFileHandler(); const fileHandler = getFileHandler();
const replyMock = mock(() => Promise.resolve()); const replyMock = mock(() => Promise.resolve());
@@ -278,8 +278,8 @@ describe('Telegram Bot Handler', () => {
}; };
await fileHandler(ctx); await fileHandler(ctx);
expect(mockForwardToStorage).toHaveBeenCalledWith('video_note_123', 'file', 'video_note'); expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith('video_note_123', 'file', 'video_note');
expect(mockInsert).toHaveBeenCalled(); expect(mockFileRepo.create).toHaveBeenCalled();
expect(replyMock).toHaveBeenCalledWith( expect(replyMock).toHaveBeenCalledWith(
expect.stringContaining('File berhasil diupload'), expect.stringContaining('File berhasil diupload'),
expect.any(Object), expect.any(Object),
@@ -289,4 +289,4 @@ describe('Telegram Bot Handler', () => {
afterAll(() => { afterAll(() => {
mock.restore(); mock.restore();
}); });
}); });