feat: implement multi-bot support for Telegram API with rate limit handling and add task queue for uploads
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
BOT_TOKEN=isi_token_bot_telegram
|
||||
ADDITIONAL_BOT_TOKENS=token_cadangan_1,token_cadangan_2,token_cadangan_3
|
||||
STORAGE_CHANNEL_ID=-1001234567890
|
||||
BASE_URL=https://tele.asepharyana.tech
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/telegram_uploader
|
||||
|
||||
@@ -19,7 +19,10 @@ Default to using Bun instead of Node.js.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import logger from './utils/logger';
|
||||
|
||||
interface AppConfig {
|
||||
botToken: string;
|
||||
additionalBotTokens: string[];
|
||||
storageChatId: number;
|
||||
baseUrl: string;
|
||||
databaseUrl: string;
|
||||
@@ -31,6 +32,12 @@ if (missing.length > 0) {
|
||||
|
||||
export const config: AppConfig = {
|
||||
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),
|
||||
baseUrl: process.env.BASE_URL!,
|
||||
databaseUrl: process.env.DATABASE_URL!,
|
||||
|
||||
+54
-22
@@ -1,23 +1,48 @@
|
||||
import { Telegraf } from 'telegraf';
|
||||
import { config } from '../env';
|
||||
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 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 {
|
||||
return await fn();
|
||||
return await action(currentBot);
|
||||
} catch (error: any) {
|
||||
const errorStr = error.message || String(error);
|
||||
const match = errorStr.match(/retry after (\d+)/i);
|
||||
if (match && retries > 0) {
|
||||
|
||||
if (match) {
|
||||
// 429 rate limit hit! Rotate bot index instantly
|
||||
const prevIndex = currentBotIndex;
|
||||
currentBotIndex = (currentBotIndex + 1) % bots.length;
|
||||
const nextIndex = currentBotIndex;
|
||||
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(`Telegram 429 Too Many Requests detected. Retrying after ${seconds} seconds...`, {
|
||||
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 withRetry(fn, retries - 1);
|
||||
return executeWithBotRetry(action, retries - 1, 0);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -41,36 +66,43 @@ export const forwardToStorage = async (
|
||||
fileType: string,
|
||||
): Promise<ForwardResult> => {
|
||||
try {
|
||||
const result: any = await enqueueUpload(async () => {
|
||||
const filePayload = { source: fileChunk, filename: fileName };
|
||||
let result: any;
|
||||
|
||||
const uploadResult = await executeWithBotRetry((activeBot) => {
|
||||
if (fileType === 'photo') {
|
||||
result = await withRetry(() => bot.telegram.sendPhoto(config.storageChatId, filePayload, {
|
||||
return activeBot.telegram.sendPhoto(config.storageChatId, filePayload, {
|
||||
caption: fileName,
|
||||
}));
|
||||
});
|
||||
} else if (fileType === 'audio') {
|
||||
result = await withRetry(() => bot.telegram.sendAudio(config.storageChatId, filePayload, {
|
||||
return activeBot.telegram.sendAudio(config.storageChatId, filePayload, {
|
||||
caption: fileName,
|
||||
}));
|
||||
});
|
||||
} else if (fileType === 'video') {
|
||||
result = await withRetry(() => bot.telegram.sendVideo(config.storageChatId, filePayload, {
|
||||
return activeBot.telegram.sendVideo(config.storageChatId, filePayload, {
|
||||
caption: fileName,
|
||||
}));
|
||||
});
|
||||
} else if (fileType === 'voice') {
|
||||
result = await withRetry(() => bot.telegram.sendVoice(config.storageChatId, filePayload, {
|
||||
return activeBot.telegram.sendVoice(config.storageChatId, filePayload, {
|
||||
caption: fileName,
|
||||
}));
|
||||
});
|
||||
} else if (fileType === 'animation') {
|
||||
result = await withRetry(() => bot.telegram.sendAnimation(config.storageChatId, filePayload, {
|
||||
return activeBot.telegram.sendAnimation(config.storageChatId, filePayload, {
|
||||
caption: fileName,
|
||||
}));
|
||||
});
|
||||
} else if (fileType === 'sticker') {
|
||||
result = await withRetry(() => bot.telegram.sendSticker(config.storageChatId, filePayload));
|
||||
return activeBot.telegram.sendSticker(config.storageChatId, filePayload);
|
||||
} else {
|
||||
result = await withRetry(() => bot.telegram.sendDocument(config.storageChatId, filePayload, {
|
||||
return activeBot.telegram.sendDocument(config.storageChatId, filePayload, {
|
||||
caption: `📁 ${fileName}`,
|
||||
}));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Advance round-robin index for next job
|
||||
currentBotIndex = (currentBotIndex + 1) % bots.length;
|
||||
|
||||
return uploadResult;
|
||||
});
|
||||
|
||||
let uploadedFile: any;
|
||||
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];
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user