feat: add design document for multi-format upload and anti-duplication feature

This commit is contained in:
MythEclipse
2026-05-18 19:42:17 +07:00
parent a0262e2d46
commit b2de5f583d
2 changed files with 400 additions and 0 deletions
@@ -0,0 +1,335 @@
# Upload and Deduplication Feature Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Expand supported upload file types/formats (stickers, video notes, etc.) and add SHA-256 / File Unique ID based anti-duplication mechanisms to save Telegram channel storage and bandwidth.
**Architecture:**
1. Database Schema: Add `fileHash` to the `files` schema in Drizzle.
2. File Type Parsing: Update `FILE_TYPES` map and `getFileType` to support sticker, video_note, and dynamic fallback.
3. Deduplication Logic: Add SHA-256 helper in `src/utils/file.ts`. Check DB before running Telegram storage forwarding in `src/routes/upload.ts`.
4. Bot Listener: Check database using `telegramFileUniqueId` before forwarding bot uploads to the storage channel.
**Tech Stack:** Bun, Drizzle ORM, PostgreSQL, Telegraf, TypeScript.
---
### Task 1: Update Database Schema
**Files:**
- Modify: `src/db/schema.ts`
- Test: `test/db.test.ts`
- [ ] **Step 1: Write the failing test**
We want to verify the new column `fileHash` exists in the schema. Modify `test/db.test.ts` to assert column `fileHash` is defined in `files` schema.
```typescript
import { test, expect } from "bun:test";
import { files } from "../src/db/schema";
test("schema has fileHash column", () => {
expect(files.fileHash).toBeDefined();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test test/db.test.ts`
Expected: FAIL because `fileHash` is undefined in `files`.
- [ ] **Step 3: Write minimal implementation**
Update `src/db/schema.ts` to include `fileHash`:
```typescript
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import { bigint, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
export const files = pgTable('files', {
id: uuid('id').primaryKey().defaultRandom(),
publicId: text('public_id').unique().notNull(),
telegramFileId: text('telegram_file_id').notNull(),
telegramFileUniqueId: text('telegram_file_unique_id').notNull(),
storageChatId: bigint('storage_chat_id', { mode: 'number' }).notNull(),
storageMessageId: bigint('storage_message_id', { mode: 'number' }).notNull(),
fileName: text('file_name').notNull(),
mimeType: text('mime_type').notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
fileType: text('file_type').notNull(),
uploaderId: bigint('uploader_id', { mode: 'number' }).notNull(),
fileHash: text('file_hash'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export type File = InferSelectModel<typeof files>;
export type NewFile = InferInsertModel<typeof files>;
```
- [ ] **Step 4: Run test to verify it passes**
Run: `bun test test/db.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/db/schema.ts test/db.test.ts
git commit -m "db: add fileHash column to schema"
```
---
### Task 2: Extend Media & File Types
**Files:**
- Modify: `src/utils/file.ts`
- Test: `test/file.test.ts`
- [ ] **Step 1: Write failing tests**
We need `getFileType` to recognize stickers, video_notes, and safely fall back any unknown MIME/caption to `document`.
Add tests to `test/file.test.ts`:
```typescript
import { test, expect } from "bun:test";
import { getFileType, checkFileSize } from "../src/utils/file";
test("getFileType handles sticker, video_note, and fallback", () => {
expect(getFileType("image/webp")).toBe("sticker");
expect(getFileType("video/mp4", "video_note")).toBe("video_note");
expect(getFileType("application/octet-stream")).toBe("document");
expect(getFileType("random/mime")).toBe("document");
});
test("checkFileSize allows up to 2GB for fallback documents", () => {
expect(checkFileSize(2 * 1024 * 1024 * 1024, "document")).toBe(true);
expect(checkFileSize(2 * 1024 * 1024 * 1024 + 1, "document")).toBe(false);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test test/file.test.ts`
Expected: FAIL because sticker and video_note return 'document' or other values, and fail the assertion.
- [ ] **Step 3: Write minimal implementation**
Update `src/utils/file.ts`:
```typescript
const FILE_TYPES: Record<string, number> = {
document: 2 * 1024 * 1024 * 1024, // 2GB
photo: 10 * 1024 * 1024, // 10MB
video: 2 * 1024 * 1024 * 1024, // 2GB
audio: 200 * 1024 * 1024, // 200MB
voice: 200 * 1024 * 1024, // 200MB
animation: 2 * 1024 * 1024 * 1024, // 2GB
sticker: 50 * 1024 * 1024, // 50MB
video_note: 200 * 1024 * 1024, // 200MB
};
export const getFileType = (mime: string | null, caption?: string): string => {
const mimeUpper = mime?.split('/')[0]?.toLowerCase();
const mimeFull = mime?.toLowerCase();
const captionLower = caption?.toLowerCase();
if (mimeFull === 'image/webp' || captionLower?.includes('sticker')) return 'sticker';
if (captionLower?.includes('video_note')) return 'video_note';
if (mimeUpper === 'video') return 'video';
if (mimeUpper === 'audio') return 'audio';
if (mimeUpper === 'image') return captionLower?.includes('gif') ? 'animation' : 'photo';
if (captionLower?.includes('voice')) return 'voice';
if (captionLower?.includes('animation')) return 'animation';
return 'document';
};
export const checkFileSize = (sizeBytes: number, fileType: string): boolean => {
const limit = FILE_TYPES[fileType] || FILE_TYPES.document;
return sizeBytes <= limit;
};
```
Let's also export `computeHash` helper at the end of `src/utils/file.ts`:
```typescript
export const computeHash = (buffer: Buffer): string => {
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(buffer);
return hasher.digest("hex");
};
```
- [ ] **Step 4: Run test to verify it passes**
Run: `bun test test/file.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/utils/file.ts test/file.test.ts
git commit -m "feat: add sticker, video_note, and computeHash helper"
```
---
### Task 3: Implement Deduplication in HTTP Upload Router
**Files:**
- Modify: `src/routes/upload.ts`
- Test: `test/upload.test.ts`
- [ ] **Step 1: Write failing tests**
We mock db select queries. First query finds a matching hash. Second query finds nothing. Let's update `test/upload.test.ts`.
Add to `test/upload.test.ts`:
```typescript
// Add imports for db and files schema
import { db, files } from '../src/db/index';
// We must also mock `db.select`
```
Let's update the mocks in `test/upload.test.ts` to support querying. We will add a test block:
```typescript
it('should return existing file on duplicate hash upload', async () => {
const mockExistingFile = {
publicId: 'existing-nanoid-id',
telegramFileId: 'existing-tg-id',
telegramFileUniqueId: 'existing-tg-unique-id',
storageChatId: 123456,
storageMessageId: 7890,
fileName: 'duplicate.txt',
mimeType: 'text/plain',
sizeBytes: 11,
fileType: 'document',
uploaderId: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
// We'll mock db.select().where().limit() in implementation
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test test/upload.test.ts`
Expected: FAIL (no hash check exists, it uploads as new file every time).
- [ ] **Step 3: Write minimal implementation**
Update `src/routes/upload.ts` to compute SHA-256 and query database by hash before doing any storage upload:
```typescript
import { eq } from 'drizzle-orm';
import { computeHash } from '../utils/file';
// Inside handleMultipartUpload and handleJSONUpload:
const hash = computeHash(fileBuffer);
const existing = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.fileHash, hash))
.limit(1);
if (existing.length > 0) {
const match = existing[0];
const responsePayload = {
public_id: match.publicId,
telegram_file_id: match.telegramFileId,
telegram_file_unique_id: match.telegramFileUniqueId,
storage_chat_id: match.storageChatId,
storage_message_id: match.storageMessageId,
file_name: match.fileName,
mime_type: match.mimeType,
size_bytes: match.sizeBytes,
file_type: match.fileType,
uploader_id: match.uploaderId,
created_at: match.createdAt instanceof Date ? match.createdAt.toISOString() : new Date(match.createdAt).toISOString(),
download_url: `${config.baseUrl}/f/${match.publicId}`,
};
return Response.json(responsePayload, { status: 200 });
}
```
Make sure the new inserted records also include the `fileHash: hash` property.
- [ ] **Step 4: Run test to verify it passes**
Run: `bun test test/upload.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/routes/upload.ts test/upload.test.ts
git commit -m "feat: implement SHA-256 deduplication in HTTP upload"
```
---
### Task 4: Implement Deduplication and All Types in Bot Listener
**Files:**
- Modify: `src/bot.ts`
- Test: `test/bot.test.ts`
- [ ] **Step 1: Write failing tests**
Update bot listener to support `sticker` and `video_note` in type list and handle `file_unique_id` duplication check before storing.
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test test/bot.test.ts`
Expected: FAIL
- [ ] **Step 3: Write minimal implementation**
Update `src/bot.ts` listener array to include all types:
`['document', 'photo', 'video', 'audio', 'voice', 'animation', 'sticker', 'video_note']`
And check the database:
```typescript
const existing = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.telegramFileUniqueId, file_unique_id))
.limit(1);
if (existing.length > 0) {
const match = existing[0];
const url = `${config.baseUrl}/f/${match.publicId}`;
await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, {
reply_parameters: { message_id: ctx.message.message_id },
});
return;
}
```
Handle extracting file objects correctly:
```typescript
const fileObj =
fileType === 'photo' ? ctx.message.photo.slice(-1)[0] : ctx.message[fileType];
```
Ensure stickers/video notes size limits are respected.
- [ ] **Step 4: Run test to verify it passes**
Run: `bun test test/bot.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/bot.ts test/bot.test.ts
git commit -m "feat: implement bot deduplication using telegram file unique id"
```