2026-05-18 07:06:21 +07:00
|
|
|
import { Telegraf } from 'telegraf';
|
2026-05-18 07:27:06 +07:00
|
|
|
import { config } from '../env';
|
2026-05-18 07:29:01 +07:00
|
|
|
import logger from './logger';
|
2026-05-18 21:18:16 +07:00
|
|
|
import { enqueueUpload } from './telegramQueue';
|
2026-05-18 07:06:21 +07:00
|
|
|
|
2026-05-18 21:18:16 +07:00
|
|
|
const botTokens = Array.from(new Set([config.botToken, ...config.additionalBotTokens]));
|
|
|
|
|
|
2026-05-29 16:53:55 +07:00
|
|
|
type FileInfoResult = {
|
|
|
|
|
result: unknown;
|
|
|
|
|
botToken: string;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-18 21:18:16 +07:00
|
|
|
const bots = botTokens.map((token) => new Telegraf(token));
|
2026-05-18 07:06:21 +07:00
|
|
|
|
2026-05-22 00:29:54 +07:00
|
|
|
let nextBotIndex = 0;
|
2026-05-18 21:18:16 +07:00
|
|
|
|
2026-05-22 00:29:54 +07:00
|
|
|
const claimBotIndex = (): number => {
|
|
|
|
|
const botIndex = nextBotIndex;
|
|
|
|
|
nextBotIndex = (nextBotIndex + 1) % bots.length;
|
|
|
|
|
return botIndex;
|
2026-05-21 22:31:55 +07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sleep = (seconds: number): Promise<void> => {
|
|
|
|
|
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const executeWithBotRetry = async <T>(
|
2026-05-29 16:53:55 +07:00
|
|
|
action: (botInstance: Telegraf, botToken: string) => Promise<T>,
|
2026-05-18 21:18:16 +07:00
|
|
|
retries = 5,
|
|
|
|
|
attemptedBots = 0,
|
2026-05-21 22:31:55 +07:00
|
|
|
): Promise<T> => {
|
2026-05-22 00:29:54 +07:00
|
|
|
const botIndex = claimBotIndex();
|
|
|
|
|
const currentBot = bots[botIndex];
|
2026-05-29 16:53:55 +07:00
|
|
|
const currentToken = botTokens[botIndex];
|
2026-05-18 21:02:38 +07:00
|
|
|
try {
|
2026-05-29 16:53:55 +07:00
|
|
|
return await action(currentBot, currentToken);
|
2026-05-21 22:31:55 +07:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
const errorStr = error instanceof Error ? error.message : String(error);
|
2026-05-18 21:02:38 +07:00
|
|
|
const match = errorStr.match(/retry after (\d+)/i);
|
2026-05-18 21:18:16 +07:00
|
|
|
|
|
|
|
|
if (match) {
|
2026-05-22 00:29:54 +07:00
|
|
|
const nextIndex = nextBotIndex;
|
|
|
|
|
const nextAttemptedBots = attemptedBots + 1;
|
2026-05-18 21:18:16 +07:00
|
|
|
|
2026-05-22 00:29:54 +07:00
|
|
|
if (nextAttemptedBots < bots.length) {
|
2026-05-18 21:19:38 +07:00
|
|
|
logger.info(
|
2026-05-22 00:29:54 +07:00
|
|
|
`Bot Index ${botIndex} hit 429. Instantly rotating to Bot Index ${nextIndex}...`,
|
2026-05-18 21:19:38 +07:00
|
|
|
);
|
2026-05-22 00:29:54 +07:00
|
|
|
return executeWithBotRetry(action, retries, nextAttemptedBots);
|
2026-05-18 21:18:16 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
2026-05-21 22:31:55 +07:00
|
|
|
await sleep(seconds);
|
2026-05-18 21:18:16 +07:00
|
|
|
return executeWithBotRetry(action, retries - 1, 0);
|
|
|
|
|
}
|
2026-05-18 21:02:38 +07:00
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-18 07:27:06 +07:00
|
|
|
interface ForwardResult {
|
|
|
|
|
telegramFileId: string;
|
|
|
|
|
telegramFileUniqueId: string;
|
|
|
|
|
storageMessageId: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 16:53:55 +07:00
|
|
|
export interface TelegramFileInfo {
|
2026-05-18 07:27:06 +07:00
|
|
|
file_size: number;
|
|
|
|
|
mime_type: string;
|
|
|
|
|
file_path: string;
|
2026-05-29 16:53:55 +07:00
|
|
|
bot_token: string;
|
2026-05-18 07:27:06 +07:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 22:31:55 +07:00
|
|
|
interface UploadedTelegramFile {
|
|
|
|
|
file_id?: string;
|
|
|
|
|
file_unique_id?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TelegramMessageResult {
|
|
|
|
|
message_id: number;
|
|
|
|
|
document?: UploadedTelegramFile;
|
|
|
|
|
photo?: UploadedTelegramFile[];
|
|
|
|
|
video?: UploadedTelegramFile;
|
|
|
|
|
audio?: UploadedTelegramFile;
|
|
|
|
|
voice?: UploadedTelegramFile;
|
|
|
|
|
animation?: UploadedTelegramFile;
|
|
|
|
|
sticker?: UploadedTelegramFile;
|
|
|
|
|
video_note?: UploadedTelegramFile;
|
|
|
|
|
[key: string]: unknown;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type FilePayload = { source: unknown; filename: string };
|
|
|
|
|
type SendPayload = { caption?: string };
|
|
|
|
|
type SendMethod = (
|
|
|
|
|
chatId: number,
|
|
|
|
|
filePayload: FilePayload,
|
|
|
|
|
payload?: SendPayload,
|
|
|
|
|
) => Promise<TelegramMessageResult>;
|
|
|
|
|
|
2026-05-29 16:53:55 +07:00
|
|
|
const sendMethodMap: Record<string, string> = {
|
2026-05-21 22:31:55 +07:00
|
|
|
photo: 'sendPhoto',
|
|
|
|
|
audio: 'sendAudio',
|
|
|
|
|
video: 'sendVideo',
|
|
|
|
|
voice: 'sendVoice',
|
|
|
|
|
animation: 'sendAnimation',
|
|
|
|
|
sticker: 'sendSticker',
|
|
|
|
|
document: 'sendDocument',
|
|
|
|
|
video_note: 'sendDocument',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const extractUploadedFile = (
|
|
|
|
|
result: TelegramMessageResult,
|
|
|
|
|
fileType: string,
|
|
|
|
|
): UploadedTelegramFile | undefined => {
|
|
|
|
|
if (result.document) return result.document;
|
|
|
|
|
if (result.photo) return result.photo?.slice(-1)[0];
|
|
|
|
|
if (result.video) return result.video;
|
|
|
|
|
if (result.audio) return result.audio;
|
|
|
|
|
if (result.voice) return result.voice;
|
|
|
|
|
if (result.animation) return result.animation;
|
|
|
|
|
if (result.sticker) return result.sticker;
|
|
|
|
|
if (result.video_note) return result.video_note;
|
|
|
|
|
return result[fileType] as UploadedTelegramFile | undefined;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildSendPayload = (fileType: string, fileName: string): SendPayload => {
|
|
|
|
|
const basePayload = { caption: fileName };
|
|
|
|
|
if (fileType === 'sticker') return {};
|
|
|
|
|
if (fileType === 'document') return { caption: `📁 ${fileName}` };
|
|
|
|
|
return basePayload;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getMediaGroupType = (fileType: string): string => {
|
|
|
|
|
if (fileType === 'photo') return 'photo';
|
|
|
|
|
if (fileType === 'video') return 'video';
|
|
|
|
|
if (fileType === 'audio') return 'audio';
|
|
|
|
|
return 'document';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface MediaGroupPayloadItem {
|
|
|
|
|
type: string;
|
|
|
|
|
media: string;
|
|
|
|
|
caption: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const buildMediaGroup = (items: MediaGroupItem[]): MediaGroupPayloadItem[] => {
|
|
|
|
|
return items.map((item) => ({
|
|
|
|
|
type: getMediaGroupType(item.fileType),
|
|
|
|
|
media: item.fileId,
|
|
|
|
|
caption: item.fileName,
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-18 07:27:06 +07:00
|
|
|
export const forwardToStorage = async (
|
2026-05-21 22:31:55 +07:00
|
|
|
fileChunk: unknown,
|
2026-05-18 07:27:06 +07:00
|
|
|
fileName: string,
|
2026-05-18 19:52:46 +07:00
|
|
|
fileType: string,
|
2026-05-18 07:27:06 +07:00
|
|
|
): Promise<ForwardResult> => {
|
2026-05-18 07:06:21 +07:00
|
|
|
try {
|
2026-05-21 22:31:55 +07:00
|
|
|
const result = await enqueueUpload(async (): Promise<TelegramMessageResult> => {
|
2026-05-18 21:18:16 +07:00
|
|
|
const filePayload = { source: fileChunk, filename: fileName };
|
2026-05-21 22:31:55 +07:00
|
|
|
const sendMethod = sendMethodMap[fileType] || 'sendDocument';
|
|
|
|
|
const payload = buildSendPayload(fileType, fileName);
|
|
|
|
|
|
2026-05-22 00:29:54 +07:00
|
|
|
return executeWithBotRetry((activeBot) => {
|
2026-05-21 22:31:55 +07:00
|
|
|
const telegram = activeBot.telegram as unknown as Record<string, SendMethod>;
|
|
|
|
|
return telegram[sendMethod](config.storageChatId, filePayload, payload);
|
2026-05-18 21:18:16 +07:00
|
|
|
});
|
|
|
|
|
});
|
2026-05-18 19:52:46 +07:00
|
|
|
|
2026-05-21 22:31:55 +07:00
|
|
|
const uploadedFile = extractUploadedFile(result, fileType);
|
2026-05-18 07:06:21 +07:00
|
|
|
logger.info('File forwarded to storage', { fileName, message: result.message_id });
|
|
|
|
|
|
|
|
|
|
return {
|
2026-05-18 08:18:55 +07:00
|
|
|
telegramFileId: uploadedFile?.file_id || '',
|
|
|
|
|
telegramFileUniqueId: uploadedFile?.file_unique_id || '',
|
2026-05-18 07:29:01 +07:00
|
|
|
storageMessageId: result.message_id,
|
2026-05-18 07:06:21 +07:00
|
|
|
};
|
2026-05-21 22:31:55 +07:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
logger.error('Failed to forward file to storage', {
|
|
|
|
|
fileName,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
});
|
2026-05-18 07:06:21 +07:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-18 21:31:20 +07:00
|
|
|
export interface MediaGroupItem {
|
|
|
|
|
fileId: string;
|
|
|
|
|
fileName: string;
|
|
|
|
|
fileType: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const forwardMediaGroupToStorage = async (
|
|
|
|
|
items: MediaGroupItem[],
|
|
|
|
|
): Promise<{
|
|
|
|
|
storageMessageId: number;
|
|
|
|
|
telegramFileIds: string[];
|
|
|
|
|
telegramFileUniqueIds: string[];
|
|
|
|
|
}> => {
|
|
|
|
|
try {
|
2026-05-21 22:31:55 +07:00
|
|
|
const result = await enqueueUpload(async (): Promise<TelegramMessageResult[]> => {
|
|
|
|
|
const mediaGroup = buildMediaGroup(items);
|
2026-05-18 21:31:20 +07:00
|
|
|
|
2026-05-22 00:29:54 +07:00
|
|
|
return executeWithBotRetry((activeBot) => {
|
2026-05-21 22:31:55 +07:00
|
|
|
const sendMediaGroup = activeBot.telegram.sendMediaGroup as unknown as (
|
|
|
|
|
chatId: number,
|
|
|
|
|
media: MediaGroupPayloadItem[],
|
|
|
|
|
) => Promise<TelegramMessageResult[]>;
|
|
|
|
|
return sendMediaGroup(config.storageChatId, mediaGroup);
|
2026-05-18 21:31:20 +07:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const messages = Array.isArray(result) ? result : [result];
|
|
|
|
|
const storageMessageId = messages[0]?.message_id || 0;
|
|
|
|
|
|
|
|
|
|
const telegramFileIds: string[] = [];
|
|
|
|
|
const telegramFileUniqueIds: string[] = [];
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < messages.length; i++) {
|
2026-05-21 22:31:55 +07:00
|
|
|
const uploadedFile = extractUploadedFile(messages[i], items[i]?.fileType || 'document');
|
2026-05-18 21:31:20 +07:00
|
|
|
telegramFileIds.push(uploadedFile?.file_id || '');
|
|
|
|
|
telegramFileUniqueIds.push(uploadedFile?.file_unique_id || '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
storageMessageId,
|
|
|
|
|
telegramFileIds,
|
|
|
|
|
telegramFileUniqueIds,
|
|
|
|
|
};
|
2026-05-21 22:31:55 +07:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
logger.error('Failed to forward media group to storage', {
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
});
|
2026-05-18 21:31:20 +07:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-22 00:29:54 +07:00
|
|
|
export const getFileInfo = async (telegramFileId: string): Promise<TelegramFileInfo> => {
|
2026-05-18 07:06:21 +07:00
|
|
|
try {
|
2026-05-29 16:53:55 +07:00
|
|
|
const { result, botToken } = await executeWithBotRetry<FileInfoResult>(
|
|
|
|
|
async (activeBot, activeToken) => ({
|
|
|
|
|
result: await activeBot.telegram.getFile(telegramFileId),
|
|
|
|
|
botToken: activeToken,
|
|
|
|
|
}),
|
2026-05-21 22:55:42 +07:00
|
|
|
);
|
2026-05-18 07:06:21 +07:00
|
|
|
|
2026-05-21 22:55:42 +07:00
|
|
|
const fileData = result as unknown as TelegramFileInfo;
|
2026-05-18 07:06:21 +07:00
|
|
|
return {
|
2026-05-21 22:55:42 +07:00
|
|
|
file_size: fileData.file_size || 0,
|
|
|
|
|
mime_type: fileData.mime_type || 'application/octet-stream',
|
|
|
|
|
file_path: fileData.file_path || '',
|
2026-05-29 16:53:55 +07:00
|
|
|
bot_token: botToken,
|
2026-05-18 07:06:21 +07:00
|
|
|
};
|
2026-05-21 22:31:55 +07:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
logger.error('Failed to get file info', {
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
});
|
2026-05-18 07:06:21 +07:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-22 00:29:54 +07:00
|
|
|
export const getBot = (): Telegraf => bots[nextBotIndex];
|
2026-05-21 22:55:42 +07:00
|
|
|
|
2026-05-22 00:29:54 +07:00
|
|
|
export const getCurrentBotIndex = (): number => nextBotIndex;
|