From ea4341fdfe443845d9c2ba17b69b272db75ead25 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Mon, 18 May 2026 19:41:16 +0700 Subject: [PATCH] feat: implement deduplication and all media types in bot listener - Expand registered bot listener to support sticker and video_note media types - Check database for existing telegramFileUniqueId to bypass forwarding duplicates - Update tests to assert deduplication and new formats handling in bot.test.ts Co-Authored-By: Claude Opus 4.7 --- src/bot.ts | 48 +++++++++++++++++-- test/bot.test.ts | 121 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 7 deletions(-) diff --git a/src/bot.ts b/src/bot.ts index 5e40b03..f959ad8 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,3 +1,4 @@ +import { eq } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import { type Context, Telegraf } from 'telegraf'; import { db, files as fileSchema } from './db'; @@ -18,11 +19,18 @@ export const startBot = async (): Promise> => { // Cast bot.on elements individually or explicitly as any to bypass Telegraf v4 typescript deprecation warnings on array syntax (bot as any).on( - ['document', 'photo', 'video', 'audio', 'voice', 'animation'], + ['document', 'photo', 'video', 'audio', 'voice', 'animation', 'sticker', 'video_note'], async (ctx: any) => { try { - const fileType: 'document' | 'photo' | 'video' | 'audio' | 'voice' | 'animation' = ctx - .message.document + const fileType: + | 'document' + | 'photo' + | 'video' + | 'audio' + | 'voice' + | 'animation' + | 'sticker' + | 'video_note' = ctx.message.document ? 'document' : ctx.message.photo ? 'photo' @@ -32,10 +40,20 @@ export const startBot = async (): Promise> => { ? 'audio' : ctx.message.voice ? 'voice' - : 'animation'; + : 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] : ctx.message[fileType]; + 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 fileName = ctx.message.document?.file_name || @@ -58,6 +76,26 @@ export const startBot = async (): Promise> => { 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); + + 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 }, + }); + logger.info('Duplicate file detected in bot, returned existing link', { + publicId: existing[0].publicId, + fileType, + fileName, + uploader: ctx.from.id, + }); + return; + } + const result = await forwardToStorage(file_id, fileName); const publicId = nanoid(); diff --git a/test/bot.test.ts b/test/bot.test.ts index e5c4233..00059a6 100644 --- a/test/bot.test.ts +++ b/test/bot.test.ts @@ -31,11 +31,24 @@ mock.module('telegraf', () => { 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, +})); mock.module('../src/db/index', () => ({ db: { insert: mockInsert, + select: mockSelect, + }, + files: { + telegramFileUniqueId: 'telegram_file_unique_id', }, - files: {}, })); // Mock forwardToStorage @@ -60,6 +73,8 @@ describe('Telegram Bot Handler', () => { mockOn.mockClear(); mockUse.mockClear(); mockInsert.mockClear(); + mockLimit.mockClear(); + mockLimit.mockResolvedValue([]); mockForwardToStorage.mockClear(); infoSpy.mockClear(); errorSpy.mockClear(); @@ -72,7 +87,7 @@ describe('Telegram Bot Handler', () => { expect(bot).toBeDefined(); expect(mockCommand).toHaveBeenCalledWith('start', expect.any(Function)); expect(mockOn).toHaveBeenCalledWith( - ['document', 'photo', 'video', 'audio', 'voice', 'animation'], + ['document', 'photo', 'video', 'audio', 'voice', 'animation', 'sticker', 'video_note'], expect.any(Function), ); expect(mockUse).toHaveBeenCalled(); @@ -154,6 +169,108 @@ describe('Telegram Bot Handler', () => { 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 fileHandler = mockOn.mock.calls[0][1]; + 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', + }, + ]); + + 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).not.toHaveBeenCalled(); + expect(mockInsert).not.toHaveBeenCalled(); + expect(replyMock).toHaveBeenCalledWith( + expect.stringContaining('already_exists_abc'), + expect.any(Object), + ); + }); + + it('should process sticker uploads', async () => { + const { startBot } = await import('../src/bot'); + await startBot(); + + const fileHandler = mockOn.mock.calls[0][1]; + const replyMock = mock(() => Promise.resolve()); + const ctx = { + message: { + message_id: 43, + sticker: { + file_id: 'sticker_123', + file_unique_id: 'sticker_uniq_123', + file_size: 1024, + }, + }, + from: { + id: 999, + }, + reply: replyMock, + }; + + await fileHandler(ctx); + expect(mockForwardToStorage).toHaveBeenCalledWith('sticker_123', 'file'); + expect(mockInsert).toHaveBeenCalled(); + expect(replyMock).toHaveBeenCalledWith( + expect.stringContaining('File berhasil diupload'), + expect.any(Object), + ); + }); + + it('should process video note uploads', async () => { + const { startBot } = await import('../src/bot'); + await startBot(); + + const fileHandler = mockOn.mock.calls[0][1]; + const replyMock = mock(() => Promise.resolve()); + const ctx = { + message: { + message_id: 44, + video_note: { + file_id: 'video_note_123', + file_unique_id: 'video_note_uniq_123', + file_size: 1024, + }, + }, + from: { + id: 999, + }, + reply: replyMock, + }; + + await fileHandler(ctx); + expect(mockForwardToStorage).toHaveBeenCalledWith('video_note_123', 'file'); + expect(mockInsert).toHaveBeenCalled(); + expect(replyMock).toHaveBeenCalledWith( + expect.stringContaining('File berhasil diupload'), + expect.any(Object), + ); + }); + afterAll(() => { mock.restore(); });