feat: implement multi-bot support for Telegram API with rate limit handling and add task queue for uploads

This commit is contained in:
MythEclipse
2026-05-18 21:18:16 +07:00
parent 721fa3db7d
commit 825e969569
6 changed files with 170 additions and 41 deletions
+1
View File
@@ -1,4 +1,5 @@
BOT_TOKEN=isi_token_bot_telegram BOT_TOKEN=isi_token_bot_telegram
ADDITIONAL_BOT_TOKENS=token_cadangan_1,token_cadangan_2,token_cadangan_3
STORAGE_CHANNEL_ID=-1001234567890 STORAGE_CHANNEL_ID=-1001234567890
BASE_URL=https://tele.asepharyana.tech BASE_URL=https://tele.asepharyana.tech
DATABASE_URL=postgresql://user:password@localhost:5432/telegram_uploader DATABASE_URL=postgresql://user:password@localhost:5432/telegram_uploader
+4 -1
View File
@@ -19,7 +19,10 @@ Default to using Bun instead of Node.js.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile - Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa. - Bun.$`ls` instead of execa.
- Rate limiter lokal dinonaktifkan (`checkRateLimit` di `src/utils/rateLimit.ts` selalu mengembalikan `true`). - Rate limiter lokal dinonaktifkan (`checkRateLimit` di `src/utils/rateLimit.ts` selalu mengembalikan `true`).
- Telegram API memiliki auto-retry otomatis jika mengembalikan error 429 (Too Many Requests) menggunakan helper `withRetry` di `src/utils/telegram.ts`. - Telegram API memiliki auto-retry otomatis jika mengembalikan error 429 (Too Many Requests) menggunakan pool Telegraf multi-bot di `src/utils/telegram.ts`.
- Multi-bot dikonfigurasi melalui `ADDITIONAL_BOT_TOKENS` (koma terpisah) di `.env` yang digabung dengan `BOT_TOKEN` utama (total 4 bot).
- Menggunakan mekanisme rotasi instan jika ada bot yang terkena rate limit 429 sebelum memutuskan untuk sleep.
- Pengiriman berkas ke Telegram dikontrol oleh antrian in-memory (`TelegramQueue` di `src/utils/telegramQueue.ts`) dengan batas konkurensi = 2 untuk mencegah rate limit berlebih.
## Testing ## Testing
+7
View File
@@ -2,6 +2,7 @@ import logger from './utils/logger';
interface AppConfig { interface AppConfig {
botToken: string; botToken: string;
additionalBotTokens: string[];
storageChatId: number; storageChatId: number;
baseUrl: string; baseUrl: string;
databaseUrl: string; databaseUrl: string;
@@ -31,6 +32,12 @@ if (missing.length > 0) {
export const config: AppConfig = { export const config: AppConfig = {
botToken: process.env.BOT_TOKEN!, botToken: process.env.BOT_TOKEN!,
additionalBotTokens: process.env.NODE_ENV === 'test'
? []
: (process.env.ADDITIONAL_BOT_TOKENS || '')
.split(',')
.map((t) => t.trim())
.filter((t) => t !== ''),
storageChatId: parseInt(process.env.STORAGE_CHANNEL_ID!, 10), storageChatId: parseInt(process.env.STORAGE_CHANNEL_ID!, 10),
baseUrl: process.env.BASE_URL!, baseUrl: process.env.BASE_URL!,
databaseUrl: process.env.DATABASE_URL!, databaseUrl: process.env.DATABASE_URL!,
+72 -40
View File
@@ -1,23 +1,48 @@
import { Telegraf } from 'telegraf'; import { Telegraf } from 'telegraf';
import { config } from '../env'; import { config } from '../env';
import logger from './logger'; import logger from './logger';
import { enqueueUpload } from './telegramQueue';
const bot = new Telegraf(config.botToken); const botTokens = Array.from(new Set([config.botToken, ...config.additionalBotTokens]));
const bots = botTokens.map((token) => new Telegraf(token));
const TELEGRAM_API_URL = `https://api.telegram.org/bot${config.botToken}/`; const TELEGRAM_API_URL = `https://api.telegram.org/bot${config.botToken}/`;
const withRetry = async <T>(fn: () => Promise<T>, retries = 5): Promise<T> => { let currentBotIndex = 0;
const executeWithBotRetry = async (
action: (botInstance: Telegraf) => Promise<any>,
retries = 5,
attemptedBots = 0,
): Promise<any> => {
const currentBot = bots[currentBotIndex];
try { try {
return await fn(); return await action(currentBot);
} catch (error: any) { } catch (error: any) {
const errorStr = error.message || String(error); const errorStr = error.message || String(error);
const match = errorStr.match(/retry after (\d+)/i); const match = errorStr.match(/retry after (\d+)/i);
if (match && retries > 0) {
const seconds = parseInt(match[1], 10); if (match) {
logger.warn(`Telegram 429 Too Many Requests detected. Retrying after ${seconds} seconds...`, { // 429 rate limit hit! Rotate bot index instantly
error: errorStr, const prevIndex = currentBotIndex;
}); currentBotIndex = (currentBotIndex + 1) % bots.length;
await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); const nextIndex = currentBotIndex;
return withRetry(fn, retries - 1); attemptedBots++;
if (attemptedBots < bots.length) {
logger.info(`Bot Index ${prevIndex} hit 429. Instantly rotating to Bot Index ${nextIndex}...`);
return executeWithBotRetry(action, retries, attemptedBots);
}
// If all bots in the pool have been tried and hit 429, sleep
if (retries > 0) {
const seconds = parseInt(match[1], 10);
logger.warn(`All bots in the pool are rate-limited. Sleeping for ${seconds} seconds...`, {
error: errorStr,
});
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
return executeWithBotRetry(action, retries - 1, 0);
}
} }
throw error; throw error;
} }
@@ -41,36 +66,43 @@ export const forwardToStorage = async (
fileType: string, fileType: string,
): Promise<ForwardResult> => { ): Promise<ForwardResult> => {
try { try {
const filePayload = { source: fileChunk, filename: fileName }; const result: any = await enqueueUpload(async () => {
let result: any; const filePayload = { source: fileChunk, filename: fileName };
const uploadResult = await executeWithBotRetry((activeBot) => {
if (fileType === 'photo') {
return activeBot.telegram.sendPhoto(config.storageChatId, filePayload, {
caption: fileName,
});
} else if (fileType === 'audio') {
return activeBot.telegram.sendAudio(config.storageChatId, filePayload, {
caption: fileName,
});
} else if (fileType === 'video') {
return activeBot.telegram.sendVideo(config.storageChatId, filePayload, {
caption: fileName,
});
} else if (fileType === 'voice') {
return activeBot.telegram.sendVoice(config.storageChatId, filePayload, {
caption: fileName,
});
} else if (fileType === 'animation') {
return activeBot.telegram.sendAnimation(config.storageChatId, filePayload, {
caption: fileName,
});
} else if (fileType === 'sticker') {
return activeBot.telegram.sendSticker(config.storageChatId, filePayload);
} else {
return activeBot.telegram.sendDocument(config.storageChatId, filePayload, {
caption: `📁 ${fileName}`,
});
}
});
if (fileType === 'photo') { // Advance round-robin index for next job
result = await withRetry(() => bot.telegram.sendPhoto(config.storageChatId, filePayload, { currentBotIndex = (currentBotIndex + 1) % bots.length;
caption: fileName,
})); return uploadResult;
} else if (fileType === 'audio') { });
result = await withRetry(() => bot.telegram.sendAudio(config.storageChatId, filePayload, {
caption: fileName,
}));
} else if (fileType === 'video') {
result = await withRetry(() => bot.telegram.sendVideo(config.storageChatId, filePayload, {
caption: fileName,
}));
} else if (fileType === 'voice') {
result = await withRetry(() => bot.telegram.sendVoice(config.storageChatId, filePayload, {
caption: fileName,
}));
} else if (fileType === 'animation') {
result = await withRetry(() => bot.telegram.sendAnimation(config.storageChatId, filePayload, {
caption: fileName,
}));
} else if (fileType === 'sticker') {
result = await withRetry(() => bot.telegram.sendSticker(config.storageChatId, filePayload));
} else {
result = await withRetry(() => bot.telegram.sendDocument(config.storageChatId, filePayload, {
caption: `📁 ${fileName}`,
}));
}
let uploadedFile: any; let uploadedFile: any;
if (result.document) uploadedFile = result.document; if (result.document) uploadedFile = result.document;
@@ -131,4 +163,4 @@ export const getFileInfo = async (
} }
}; };
export const getBot = (): Telegraf => bot; export const getBot = (): Telegraf => bots[0];
+47
View File
@@ -0,0 +1,47 @@
type QueueTask<T> = {
task: () => Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
};
class TelegramQueue {
private activeCount = 0;
private queue: QueueTask<any>[] = [];
private concurrency: number;
constructor(concurrency = 2) {
this.concurrency = concurrency;
}
public enqueue<T>(task: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this.processNext();
});
}
private async processNext(): Promise<void> {
if (this.activeCount >= this.concurrency || this.queue.length === 0) {
return;
}
const item = this.queue.shift()!;
this.activeCount++;
try {
const result = await item.task();
item.resolve(result);
} catch (error) {
item.reject(error);
} finally {
this.activeCount--;
this.processNext();
}
}
}
const telegramQueue = new TelegramQueue(2);
export const enqueueUpload = <T>(task: () => Promise<T>): Promise<T> => {
return telegramQueue.enqueue(task);
};
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'bun:test';
import { enqueueUpload } from '../src/utils/telegramQueue';
describe('Telegram Queue', () => {
it('should process tasks in order and limit concurrency', async () => {
let activeTasks = 0;
let maxActiveTasks = 0;
const executionOrder: number[] = [];
const createTask = (id: number, delayMs: number) => {
return async () => {
activeTasks++;
if (activeTasks > maxActiveTasks) {
maxActiveTasks = activeTasks;
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
executionOrder.push(id);
activeTasks--;
return id;
};
};
// Enqueue 4 tasks with delays
const promises = [
enqueueUpload(createTask(1, 50)),
enqueueUpload(createTask(2, 20)),
enqueueUpload(createTask(3, 10)),
enqueueUpload(createTask(4, 5)),
];
const results = await Promise.all(promises);
expect(results).toEqual([1, 2, 3, 4]);
// Concurrency limit is 2, so maximum active tasks at any time should be <= 2
expect(maxActiveTasks).toBeLessThanOrEqual(2);
});
});