diff --git a/src/bot.js b/src/bot.js new file mode 100644 index 0000000..eb72c2c --- /dev/null +++ b/src/bot.js @@ -0,0 +1,90 @@ +import { Telegraf } from 'telegraf'; +import logger from './utils/logger.js'; +import { config } from './env.js'; +import { db, files as fileSchema } from './db/index.js'; +import { nanoid } from 'nanoid'; +import { forwardToStorage } from './utils/telegram.js'; + +export const startBot = async () => { + try { + const bot = new Telegraf(config.botToken); + + bot.command('start', async (ctx) => { + await ctx.reply( + `👋 Halo! Kirimkan file (document, photo, video, audio, voice, animation) ke bot ini. ` + + `File akan disimpan di private channel dan kamu dapat download link permanen.` + ); + }); + + bot.on(['document', 'photo', 'video', 'audio', 'voice', 'animation'], async (ctx) => { + try { + const fileType = ctx.message.document ? 'document' : + ctx.message.photo ? 'photo' : + ctx.message.video ? 'video' : + ctx.message.audio ? 'audio' : + ctx.message.voice ? 'voice' : 'animation'; + + const fileObj = fileType === 'photo' ? ctx.message.photo.slice(-1)[0] : ctx.message[fileType]; + const { file_id, file_unique_id, file_size, mime_type } = fileObj; + const fileName = ctx.message.document?.file_name || + ctx.message.photo?.slice(-1)[0]?.file_name || + ctx.message.video?.file_name || + ctx.message.audio?.file_name || + 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; + + if (file_size > maxSize) { + return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`); + } + + const result = await forwardToStorage(file_id, fileName); + const publicId = nanoid(); + + const uploaded = { + public_id: publicId, + telegram_file_id: result.telegramFileId, + telegram_file_unique_id: result.telegramFileUniqueId, + storage_chat_id: config.storageChatId, + storage_message_id: result.storageMessageId, + file_name: fileName, + mime_type: mime_type, + size_bytes: file_size, + file_type: fileType, + uploader_id: ctx.from.id, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }; + + await db.insert(fileSchema).values(uploaded); + + const url = `${config.baseUrl}/f/${publicId}`; + await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, { + reply_parameters: { message_id: ctx.message.message_id } + }); + + logger.info('File uploaded via bot', { publicId, fileType, fileName, uploader: ctx.from.id }); + } catch (error) { + logger.error('Bot file handler error', { error: error.message, chat_id: ctx.chat?.id }); + await ctx.reply('❌ Gagal mengupload file. Coba lagi nanti.'); + } + }); + + bot.use((ctx, next) => { + logger.info('Telegram event received', { type: ctx.update.type, chat_id: ctx.chat?.id }); + return next(); + }); + + await bot.launch(); + + logger.info('Telegram bot started', { botToken: config.botToken?.substring(0, 10) + '...' }); + + return bot; + } catch (error) { + logger.error('Failed to start bot', { error: error.message }); + throw error; + } +}; diff --git a/test/bot.test.js b/test/bot.test.js new file mode 100644 index 0000000..c251974 --- /dev/null +++ b/test/bot.test.js @@ -0,0 +1,148 @@ +import { describe, it, expect, mock, spyOn, beforeEach } from "bun:test"; +import logger from "../src/utils/logger.js"; + +// Mock environment +process.env.BOT_TOKEN = "8605908810:AAFpUzlIBktfd_7wpEj7zMJob2CFxvG-ZGY"; +process.env.STORAGE_CHANNEL_ID = "-1003996572954"; +process.env.BASE_URL = "https://tele.asepharyana.tech"; + +// 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; + } + } + }; +}); + +// Mock database +const mockInsert = mock(() => ({ + values: mock(() => Promise.resolve()) +})); +mock.module("../src/db/index.js", () => ({ + db: { + insert: mockInsert + }, + files: {} +})); + +// Mock forwardToStorage +const mockForwardToStorage = mock(() => Promise.resolve({ + telegramFileId: "stored_file_id", + telegramFileUniqueId: "stored_unique_id", + storageMessageId: 9999 +})); +mock.module("../src/utils/telegram.js", () => ({ + forwardToStorage: mockForwardToStorage +})); + +const infoSpy = spyOn(logger, "info"); +const errorSpy = spyOn(logger, "error"); + +describe("Telegram Bot Handler", () => { + beforeEach(() => { + mockLaunch.mockClear(); + mockCommand.mockClear(); + mockOn.mockClear(); + mockUse.mockClear(); + mockInsert.mockClear(); + mockForwardToStorage.mockClear(); + infoSpy.mockClear(); + errorSpy.mockClear(); + }); + + it("should initialize and launch the bot", async () => { + const { startBot } = await import("../src/bot.js"); + const bot = await startBot(); + + expect(bot).toBeDefined(); + expect(mockCommand).toHaveBeenCalledWith("start", expect.any(Function)); + expect(mockOn).toHaveBeenCalledWith( + ["document", "photo", "video", "audio", "voice", "animation"], + expect.any(Function) + ); + expect(mockUse).toHaveBeenCalled(); + expect(mockLaunch).toHaveBeenCalled(); + }); + + it("should handle /start command", async () => { + const { startBot } = await import("../src/bot.js"); + await startBot(); + + const startHandler = mockCommand.mock.calls.find(call => call[0] === "start")[1]; + const replyMock = mock(() => Promise.resolve()); + const ctx = { + reply: replyMock + }; + + await startHandler(ctx); + expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("Halo")); + }); + + it("should process document uploads and save to db", async () => { + const { startBot } = await import("../src/bot.js"); + await startBot(); + + const fileHandler = mockOn.mock.calls[0][1]; + const replyMock = mock(() => Promise.resolve()); + const ctx = { + message: { + message_id: 42, + document: { + file_id: "doc_123", + file_unique_id: "doc_uniq_123", + file_size: 1024, + mime_type: "application/pdf", + file_name: "cv.pdf" + } + }, + from: { + id: 999 + }, + reply: replyMock + }; + + await fileHandler(ctx); + expect(mockForwardToStorage).toHaveBeenCalledWith("doc_123", "cv.pdf"); + expect(mockInsert).toHaveBeenCalled(); + expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("File berhasil diupload"), expect.any(Object)); + }); + + it("should reject uploads exceeding max size limit", async () => { + const { startBot } = await import("../src/bot.js"); + await startBot(); + + const fileHandler = mockOn.mock.calls[0][1]; + const replyMock = mock(() => Promise.resolve()); + const ctx = { + message: { + message_id: 42, + photo: [{ + file_id: "photo_123", + file_unique_id: "photo_uniq_123", + file_size: 20 * 1024 * 1024, // 20MB exceeds 10MB limit + mime_type: "image/jpeg" + }] + }, + from: { + id: 999 + }, + reply: replyMock + }; + + await fileHandler(ctx); + expect(mockForwardToStorage).not.toHaveBeenCalled(); + expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("exceeds")); + }); +});