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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
04a2e52250
commit
ea4341fdfe
+43
-5
@@ -1,3 +1,4 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
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 { db, files as fileSchema } from './db';
|
||||||
@@ -18,11 +19,18 @@ export const startBot = async (): Promise<Telegraf<Context>> => {
|
|||||||
|
|
||||||
// Cast bot.on elements individually or explicitly as any to bypass Telegraf v4 typescript deprecation warnings on array syntax
|
// Cast bot.on elements individually or explicitly as any to bypass Telegraf v4 typescript deprecation warnings on array syntax
|
||||||
(bot as any).on(
|
(bot as any).on(
|
||||||
['document', 'photo', 'video', 'audio', 'voice', 'animation'],
|
['document', 'photo', 'video', 'audio', 'voice', 'animation', 'sticker', 'video_note'],
|
||||||
async (ctx: any) => {
|
async (ctx: any) => {
|
||||||
try {
|
try {
|
||||||
const fileType: 'document' | 'photo' | 'video' | 'audio' | 'voice' | 'animation' = ctx
|
const fileType:
|
||||||
.message.document
|
| 'document'
|
||||||
|
| 'photo'
|
||||||
|
| 'video'
|
||||||
|
| 'audio'
|
||||||
|
| 'voice'
|
||||||
|
| 'animation'
|
||||||
|
| 'sticker'
|
||||||
|
| 'video_note' = ctx.message.document
|
||||||
? 'document'
|
? 'document'
|
||||||
: ctx.message.photo
|
: ctx.message.photo
|
||||||
? 'photo'
|
? 'photo'
|
||||||
@@ -32,10 +40,20 @@ export const startBot = async (): Promise<Telegraf<Context>> => {
|
|||||||
? 'audio'
|
? 'audio'
|
||||||
: ctx.message.voice
|
: ctx.message.voice
|
||||||
? 'voice'
|
? 'voice'
|
||||||
: 'animation';
|
: ctx.message.animation
|
||||||
|
? 'animation'
|
||||||
|
: ctx.message.sticker
|
||||||
|
? 'sticker'
|
||||||
|
: ctx.message.video_note
|
||||||
|
? 'video_note'
|
||||||
|
: 'document';
|
||||||
|
|
||||||
const fileObj =
|
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 { file_id, file_size, mime_type } = fileObj;
|
||||||
const fileName =
|
const fileName =
|
||||||
ctx.message.document?.file_name ||
|
ctx.message.document?.file_name ||
|
||||||
@@ -58,6 +76,26 @@ 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 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 result = await forwardToStorage(file_id, fileName);
|
||||||
const publicId = nanoid();
|
const publicId = nanoid();
|
||||||
|
|
||||||
|
|||||||
+119
-2
@@ -31,11 +31,24 @@ mock.module('telegraf', () => {
|
|||||||
const mockInsert = mock(() => ({
|
const mockInsert = mock(() => ({
|
||||||
values: mock(() => Promise.resolve()),
|
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', () => ({
|
mock.module('../src/db/index', () => ({
|
||||||
db: {
|
db: {
|
||||||
insert: mockInsert,
|
insert: mockInsert,
|
||||||
|
select: mockSelect,
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
telegramFileUniqueId: 'telegram_file_unique_id',
|
||||||
},
|
},
|
||||||
files: {},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock forwardToStorage
|
// Mock forwardToStorage
|
||||||
@@ -60,6 +73,8 @@ describe('Telegram Bot Handler', () => {
|
|||||||
mockOn.mockClear();
|
mockOn.mockClear();
|
||||||
mockUse.mockClear();
|
mockUse.mockClear();
|
||||||
mockInsert.mockClear();
|
mockInsert.mockClear();
|
||||||
|
mockLimit.mockClear();
|
||||||
|
mockLimit.mockResolvedValue([]);
|
||||||
mockForwardToStorage.mockClear();
|
mockForwardToStorage.mockClear();
|
||||||
infoSpy.mockClear();
|
infoSpy.mockClear();
|
||||||
errorSpy.mockClear();
|
errorSpy.mockClear();
|
||||||
@@ -72,7 +87,7 @@ describe('Telegram Bot Handler', () => {
|
|||||||
expect(bot).toBeDefined();
|
expect(bot).toBeDefined();
|
||||||
expect(mockCommand).toHaveBeenCalledWith('start', expect.any(Function));
|
expect(mockCommand).toHaveBeenCalledWith('start', expect.any(Function));
|
||||||
expect(mockOn).toHaveBeenCalledWith(
|
expect(mockOn).toHaveBeenCalledWith(
|
||||||
['document', 'photo', 'video', 'audio', 'voice', 'animation'],
|
['document', 'photo', 'video', 'audio', 'voice', 'animation', 'sticker', 'video_note'],
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
expect(mockUse).toHaveBeenCalled();
|
expect(mockUse).toHaveBeenCalled();
|
||||||
@@ -154,6 +169,108 @@ describe('Telegram Bot Handler', () => {
|
|||||||
expect(replyMock).toHaveBeenCalledWith(expect.stringContaining('exceeds'));
|
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(() => {
|
afterAll(() => {
|
||||||
mock.restore();
|
mock.restore();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user