feat: add Telegram bot handler
Implement Telegraf bot instance with file upload/forward handlers and mock test verification. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
be93a06918
commit
4996d7548a
+90
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user