import express from 'express'; import fs from 'fs/promises'; import fssync from 'fs'; import path from 'path'; import fetch from 'node-fetch'; import { glob } from 'glob'; import { createProxyMiddleware } from 'http-proxy-middleware'; import crypto from 'crypto'; const APP_PORT = parseInt(process.env.PORT || '7860', 10); const INTERNAL_TELEGRAM_API_PORT = parseInt(process.env.INTERNAL_TELEGRAM_API_PORT || '8081', 10); const TELEGRAM_DATA_DIR = process.env.TELEGRAM_DATA_DIR || '/var/lib/telegram-bot-api'; const FILES_TTL_HOURS = parseInt(process.env.FILES_TTL || '-1', 10); const PROXY_TELEGRAM_PATH_PREFIX = process.env.PROXY_TELEGRAM_PATH_PREFIX || '/tg'; const GITHUB_USERNAME = process.env.GITHUB_USERNAME || ''; const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''; const ENV_GIST_ID = process.env.ENV_GIST_ID || ''; const LINK_ENCRYPTION_KEY_PASSPHRASE = process.env.LINK_ENCRYPTION_KEY; const DEFAULT_LINK_EXPIRY_HOURS = parseInt(process.env.DEFAULT_LINK_EXPIRY_HOURS || '24', 10); const app = express(); let encryptionKeyBuffer; if (LINK_ENCRYPTION_KEY_PASSPHRASE) { encryptionKeyBuffer = crypto.createHash('sha256').update(String(LINK_ENCRYPTION_KEY_PASSPHRASE)).digest(); } else { console.warn("[Crypto] LINK_ENCRYPTION_KEY is not set. Encrypted link generation will be disabled."); } const CRYPTO_ALGORITHM = 'aes-256-gcm'; const CRYPTO_IV_LENGTH_BYTES = 12; const CRYPTO_AUTH_TAG_LENGTH_BYTES = 16; function calculateExpiryTimestamp(hours) { if (hours === -1) return null; return Date.now() + hours * 60 * 60 * 1000; } function encryptPayload(payloadObject) { if (!encryptionKeyBuffer) return null; try { const text = JSON.stringify(payloadObject); const iv = crypto.randomBytes(CRYPTO_IV_LENGTH_BYTES); const cipher = crypto.createCipheriv(CRYPTO_ALGORITHM, encryptionKeyBuffer, iv); let encrypted = cipher.update(text, 'utf8', 'hex'); encrypted += cipher.final('hex'); const authTag = cipher.getAuthTag(); return `${iv.toString('hex')}.${encrypted}.${authTag.toString('hex')}`; } catch (error) { console.error("[Crypto] Encryption failed:", error); return null; } } function decryptPayload(encryptedString) { if (!encryptionKeyBuffer) return null; try { const parts = encryptedString.split('.'); if (parts.length !== 3) throw new Error("Invalid encrypted string format"); const iv = Buffer.from(parts[0], 'hex'); const encryptedData = parts[1]; const authTag = Buffer.from(parts[2], 'hex'); if (iv.length !== CRYPTO_IV_LENGTH_BYTES) throw new Error("Invalid IV length"); if (authTag.length !== CRYPTO_AUTH_TAG_LENGTH_BYTES) throw new Error("Invalid authTag length"); const decipher = crypto.createDecipheriv(CRYPTO_ALGORITHM, encryptionKeyBuffer, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(encryptedData, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return JSON.parse(decrypted); } catch (error) { console.error("[Crypto] Decryption failed:", error.message); return null; } } function formatBytes(bytes, decimals = 2) { if (bytes === 0) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } async function getDirectoryStats(dirPath) { try { const files = await glob(`${dirPath}/**/*`, { nodir: true, dot: true }); let totalSize = 0; for (const file of files) { try { const stats = await fs.stat(file); totalSize += stats.size; } catch (e) { } } return { fileCount: files.length, totalSizeBytes: totalSize, totalSizeHuman: formatBytes(totalSize), }; } catch (error) { console.error(`Error getting directory stats for ${dirPath}:`, error); return { fileCount: 0, totalSizeBytes: 0, totalSizeHuman: '0 Bytes', error: error.message }; } } async function cleanupOldFiles(dirPath, ttlHours) { if (ttlHours <= 0) { return { processed: 0, deleted: 0, errors: 0, message: 'Cleanup disabled (FILES_TTL <= 0)' }; } console.log(`[TTL] Starting cleanup for files older than ${ttlHours} hours in ${dirPath}`); const now = Date.now(); const ttlMs = ttlHours * 60 * 60 * 1000; let processed = 0, deleted = 0, errors = 0; try { const files = await glob(`${dirPath}/**/*`, { nodir: true, dot: true, stat: true, withFileTypes: false }); for (const file of files) { processed++; try { const stats = await fs.stat(file); if (stats.isFile()) { if ((now - stats.mtimeMs) > ttlMs) { await fs.unlink(file); deleted++; } } } catch (e) { errors++; console.error(`[TTL] Error processing file ${file}:`, e.message); } } } catch (globError) { console.error(`[TTL] Error during glob search:`, globError); return { processed, deleted, errors: errors + 1, message: `Glob error: ${globError.message}` }; } const result = { processed, deleted, errors, message: `Cleanup completed. Processed: ${processed}, Deleted: ${deleted}, Errors: ${errors}` }; console.log(`[TTL] ${result.message}`); return result; } async function updateEnvGistInGithub() { if (!GITHUB_USERNAME || !GITHUB_TOKEN || !ENV_GIST_ID) { console.warn('Gist update skipped: GITHUB_USERNAME, GITHUB_TOKEN, or ENV_GIST_ID is not set.'); return; } const gistFilename = `hf_space_telegram_bot_api_proxy_info.json`; let aggregateData = { description: `Hugging Face Spaces with Telegram Bot API Proxy & Tools - Aggregated Info.`, schema_version: "1.2.1", spaces_info: {} }; try { const gistFetchResponse = await fetch(`https://api.github.com/gists/${ENV_GIST_ID}`, { headers: { 'Authorization': `token ${GITHUB_TOKEN}`, 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'HFSpaceTelegramBotAPIProxy' } }); if (gistFetchResponse.ok) { const existingGist = await gistFetchResponse.json(); if (existingGist.files && existingGist.files[gistFilename] && existingGist.files[gistFilename].content) { try { const parsedContent = JSON.parse(existingGist.files[gistFilename].content); if (parsedContent && typeof parsedContent.spaces_info === 'object') { aggregateData = parsedContent; } } catch (parseError) {} } } } catch (fetchError) {} const spaceId = process.env.SPACE_ID || 'unknown_space'; const spaceHost = process.env.SPACE_HOST; const publicSpaceUrl = spaceHost ? `https://${spaceHost}` : 'N/A (SPACE_HOST not set)'; const currentSpaceEntry = { last_updated: new Date().toISOString(), public_space_url: publicSpaceUrl, telegram_api_proxy_url_template: `${publicSpaceUrl}${PROXY_TELEGRAM_PATH_PREFIX}/bot/`, stats_url: `${publicSpaceUrl}/stats`, file_operations_base_url: `${publicSpaceUrl}/file`, example_urls: { list_files: `${publicSpaceUrl}/file/list?token=`, get_file_info_augmented: `${publicSpaceUrl}/file//getFile?file_id=`, download_file_by_id: `${publicSpaceUrl}/file//downloadFile?file_id=`, download_file_by_path: `${publicSpaceUrl}/file//`, delete_file_by_path: `${publicSpaceUrl}/file/deleteFile?file=/`, delete_file_by_id: `${publicSpaceUrl}/file//deleteById?file_id=`, download_encrypted: `${publicSpaceUrl}/file/downloadEncrypted?payload=`, }, app_main_port_on_space: APP_PORT, internal_telegram_api_port: INTERNAL_TELEGRAM_API_PORT, files_ttl_hours: FILES_TTL_HOURS > 0 ? FILES_TTL_HOURS : 'disabled', link_encryption_enabled: !!encryptionKeyBuffer, default_link_expiry_hours: DEFAULT_LINK_EXPIRY_HOURS }; if (!aggregateData.spaces_info) aggregateData.spaces_info = {}; aggregateData.spaces_info[spaceId] = currentSpaceEntry; aggregateData.last_updated_by_space = spaceId; aggregateData.overall_last_updated = new Date().toISOString(); const gistPatchData = { description: aggregateData.description, files: { [gistFilename]: { content: JSON.stringify(aggregateData, null, 2) } }, }; try { const response = await fetch(`https://api.github.com/gists/${ENV_GIST_ID}`, { method: 'PATCH', headers: { 'Authorization': `token ${GITHUB_TOKEN}`, 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'HFSpaceTelegramBotAPIProxy', 'Content-Type': 'application/json', }, body: JSON.stringify(gistPatchData), }); if (!response.ok) throw new Error(`GitHub API error: ${response.status} ${await response.text()}`); console.log(`Gist ${ENV_GIST_ID} (file: ${gistFilename}) updated successfully.`); } catch (error) { console.error('Error updating Gist:', error.message); } } app.get('/stats', async (req, res) => { const stats = await getDirectoryStats(TELEGRAM_DATA_DIR); let ttlResult = { message: "TTL cleanup not run or disabled." }; if (req.query.run_ttl_now === 'true' && FILES_TTL_HOURS > 0) { ttlResult = await cleanupOldFiles(TELEGRAM_DATA_DIR, FILES_TTL_HOURS); } res.json({ directory: TELEGRAM_DATA_DIR, ...stats, files_ttl_hours: FILES_TTL_HOURS > 0 ? FILES_TTL_HOURS : 'disabled', ttl_cleanup_on_this_request: ttlResult }); }); const fileRouter = express.Router(); fileRouter.get('/list', async (req, res) => { const botToken = req.query.token; if (!botToken) return res.status(400).json({ error: 'Missing bot_token query parameter.' }); const botDir = path.join(TELEGRAM_DATA_DIR, botToken); try { if (!fssync.existsSync(botDir) || !fssync.statSync(botDir).isDirectory()) { return res.status(404).json({ error: 'Bot directory not found or is not a directory.' }); } const filesInBotDir = await glob(`${botDir}/**/*`, { nodir: true, dot: true }); const fileDetails = []; let totalSizeForToken = 0; for (const filePath of filesInBotDir) { try { const stats = await fs.stat(filePath); fileDetails.push({ path: path.relative(TELEGRAM_DATA_DIR, filePath), size: stats.size, sizeHuman: formatBytes(stats.size), lastModified: stats.mtime, }); totalSizeForToken += stats.size; } catch (statError) { fileDetails.push({ path: path.relative(TELEGRAM_DATA_DIR, filePath), error: "Could not stat file" }); } } res.json({ bot_token: botToken, directory: botDir, file_count: fileDetails.length, total_size_bytes: totalSizeForToken, total_size_human: formatBytes(totalSizeForToken), files: fileDetails }); } catch (error) { console.error(`Error listing files for token ${botToken}:`, error); res.status(500).json({ error: 'Failed to list files.', details: error.message }); } }); fileRouter.get('/deleteFile', async (req, res) => { const relativePath = req.query.file; if (!relativePath || relativePath.includes('..') || !relativePath.trim()) { return res.status(400).json({ error: 'Invalid or missing file path query parameter.' }); } const absoluteRequestedPath = path.normalize(path.join(TELEGRAM_DATA_DIR, relativePath)); if (!path.resolve(absoluteRequestedPath).startsWith(path.resolve(TELEGRAM_DATA_DIR))) { return res.status(403).json({ error: 'Forbidden: Path traversal attempt detected.' }); } try { if (fssync.existsSync(absoluteRequestedPath) && fssync.statSync(absoluteRequestedPath).isFile()) { await fs.unlink(absoluteRequestedPath); res.json({ success: true, message: `File deleted: ${relativePath}` }); } else { res.status(404).json({ error: 'File not found or is not a file.' }); } } catch (error) { console.error(`Error deleting file ${relativePath}:`, error); res.status(500).json({ error: 'Failed to delete file.', details: error.message }); } }); fileRouter.get('/:botToken/deleteById', async (req, res) => { const { botToken } = req.params; const { file_id } = req.query; if (!botToken || !file_id) { return res.status(400).json({ error: 'Missing botToken in path or file_id in query.' }); } try { const tgApiResponse = await fetch(`http://localhost:${INTERNAL_TELEGRAM_API_PORT}/bot${botToken}/getFile?file_id=${file_id}`); if (!tgApiResponse.ok) { const errorData = await tgApiResponse.json().catch(() => ({ description: "Unknown error from Telegram API" })); return res.status(tgApiResponse.status).json({ error: `Telegram API error (getFile): ${errorData.description || tgApiResponse.statusText}` }); } const fileInfo = await tgApiResponse.json(); if (!fileInfo.ok || !fileInfo.result || !fileInfo.result.file_path) { return res.status(404).json({ error: 'File not found or path not available via Telegram API (getFile).', details: fileInfo }); } const filePathOnDisk = fileInfo.result.file_path; const resolvedDataDir = path.resolve(TELEGRAM_DATA_DIR); const resolvedFilePathOnDisk = path.resolve(filePathOnDisk); if (!resolvedFilePathOnDisk.startsWith(resolvedDataDir)) { console.error(`[Security] Attempt to delete file outside TELEGRAM_DATA_DIR. Requested: ${filePathOnDisk}, Allowed base: ${resolvedDataDir}`); return res.status(403).json({ error: 'Forbidden: File path is outside allowed directory.' }); } if (fssync.existsSync(resolvedFilePathOnDisk) && fssync.statSync(resolvedFilePathOnDisk).isFile()) { await fs.unlink(resolvedFilePathOnDisk); res.json({ success: true, message: `File deleted successfully. Path: ${filePathOnDisk}`, file_id: file_id }); } else { res.status(404).json({ error: 'File not found on disk for the given file_id.', path_attempted: filePathOnDisk }); } } catch (error) { console.error(`Error in /:botToken/deleteById for file_id ${file_id}:`, error); res.status(500).json({ error: 'Server error during file deletion by ID.', details: error.message }); } }); fileRouter.get('/downloadEncrypted', async (req, res) => { const encryptedPayload = req.query.payload; if (!encryptedPayload) return res.status(400).json({ error: 'Missing payload.' }); if (!encryptionKeyBuffer) return res.status(503).json({ error: 'Encryption/decryption is not configured on the server (LINK_ENCRYPTION_KEY not set).' }); const decrypted = decryptPayload(encryptedPayload); if (!decrypted) { return res.status(400).json({ error: 'Invalid or undecryptable payload.' }); } if (decrypted.expiresAt && Date.now() > decrypted.expiresAt) { return res.status(410).json({ error: 'Link expired.' }); } let filePathOnDisk; let originalFilename; try { if (decrypted.type === 'id' && decrypted.bot_token && decrypted.file_id) { const tgApiResponse = await fetch(`http://localhost:${INTERNAL_TELEGRAM_API_PORT}/bot${decrypted.bot_token}/getFile?file_id=${decrypted.file_id}`); if (!tgApiResponse.ok) { const errorData = await tgApiResponse.json().catch(() => ({ description: "Unknown error from Telegram API" })); return res.status(tgApiResponse.status).json({ error: `Telegram API error: ${errorData.description || tgApiResponse.statusText}` }); } const fileInfo = await tgApiResponse.json(); if (!fileInfo.ok || !fileInfo.result || !fileInfo.result.file_path) { return res.status(404).json({ error: 'File not found or path not available via Telegram API.', details: fileInfo }); } filePathOnDisk = fileInfo.result.file_path; originalFilename = path.basename(filePathOnDisk); } else if (decrypted.type === 'path' && decrypted.file_path_relative_to_data_dir) { filePathOnDisk = path.join(TELEGRAM_DATA_DIR, decrypted.file_path_relative_to_data_dir); originalFilename = path.basename(filePathOnDisk); } else { return res.status(400).json({ error: 'Invalid payload structure.' }); } const resolvedDataDir = path.resolve(TELEGRAM_DATA_DIR); const resolvedFilePathOnDisk = path.resolve(filePathOnDisk); if (!resolvedFilePathOnDisk.startsWith(resolvedDataDir)) { console.error(`[Security] Attempt to access file outside TELEGRAM_DATA_DIR. Requested: ${filePathOnDisk}, Allowed base: ${resolvedDataDir}`); return res.status(403).send('Forbidden: File path is outside allowed directory.'); } if (fssync.existsSync(resolvedFilePathOnDisk) && fssync.statSync(resolvedFilePathOnDisk).isFile()) { res.sendFile(resolvedFilePathOnDisk, { headers: { 'Content-Disposition': `attachment; filename="${originalFilename}"` } }, err => { if (err && !res.headersSent) res.status(500).send('Error sending file.'); }); } else { res.status(404).send('File not found on disk.'); } } catch (error) { console.error(`Error in /downloadEncrypted:`, error); res.status(500).json({ error: 'Server error during encrypted download process.', details: error.message }); } }); fileRouter.get('/:botToken/getFile', async (req, res) => { const { botToken } = req.params; const { file_id } = req.query; const customExpiryHours = req.query.link_expiry_hours ? parseInt(req.query.link_expiry_hours, 10) : DEFAULT_LINK_EXPIRY_HOURS; if (!botToken || !file_id) { return res.status(400).json({ error: 'Missing botToken in path or file_id in query.' }); } try { const tgApiResponse = await fetch(`http://localhost:${INTERNAL_TELEGRAM_API_PORT}/bot${botToken}/getFile?file_id=${file_id}`); if (!tgApiResponse.ok) { const errorData = await tgApiResponse.json().catch(() => ({ description: "Unknown error from Telegram API" })); return res.status(tgApiResponse.status).json({ error: `Telegram API error: ${errorData.description || tgApiResponse.statusText}` }); } const fileInfo = await tgApiResponse.json(); if (fileInfo.ok && fileInfo.result && fileInfo.result.file_path) { const spaceHost = process.env.SPACE_HOST; const publicSpaceUrl = spaceHost ? `https://${spaceHost}` : `${req.protocol}://${req.get('host')}`; const apiFilePath = fileInfo.result.file_path; let relativePathForLink = ""; const resolvedNormalizedDataDir = path.resolve(TELEGRAM_DATA_DIR); const resolvedApiFilePath = path.resolve(apiFilePath); if (resolvedApiFilePath.startsWith(resolvedNormalizedDataDir)) { relativePathForLink = path.relative(resolvedNormalizedDataDir, resolvedApiFilePath); } else { relativePathForLink = apiFilePath; } const expiresAt = calculateExpiryTimestamp(customExpiryHours); const urlSafeRelativePath = relativePathForLink.split(path.sep).join('/'); if (!relativePathForLink.startsWith('..') && relativePathForLink) { fileInfo.result.direct_download_link_by_path = `${publicSpaceUrl}/file/${urlSafeRelativePath}`; fileInfo.result.delete_link_by_path = `${publicSpaceUrl}/file/deleteFile?file=${encodeURIComponent(urlSafeRelativePath)}`; if (encryptionKeyBuffer) { const encryptedPayloadByPath = encryptPayload({ type: 'path', file_path_relative_to_data_dir: relativePathForLink, expiresAt }); if (encryptedPayloadByPath) { fileInfo.result.encrypted_download_link_by_path = `${publicSpaceUrl}/file/downloadEncrypted?payload=${encryptedPayloadByPath}`; } } } else { fileInfo.result.direct_download_link_by_path = "Error: could not construct safe relative path."; } fileInfo.result.direct_download_link_by_id = `${publicSpaceUrl}/file/${botToken}/downloadFile?file_id=${file_id}`; fileInfo.result.delete_link_by_id = `${publicSpaceUrl}/file/${botToken}/deleteById?file_id=${file_id}`; if (encryptionKeyBuffer) { const encryptedPayloadById = encryptPayload({ type: 'id', bot_token: botToken, file_id: file_id, expiresAt }); if (encryptedPayloadById) { fileInfo.result.encrypted_download_link_by_id = `${publicSpaceUrl}/file/downloadEncrypted?payload=${encryptedPayloadById}`; } } } res.json(fileInfo); } catch (error) { console.error(`Error in /:botToken/getFile for file_id ${file_id}:`, error); res.status(500).json({ error: 'Server error processing getFile request.', details: error.message }); } }); fileRouter.get('/:botToken/downloadFile', async (req, res) => { const { botToken } = req.params; const { file_id } = req.query; if (!botToken || !file_id) { return res.status(400).json({ error: 'Missing botToken in path or file_id in query.' }); } try { const tgApiResponse = await fetch(`http://localhost:${INTERNAL_TELEGRAM_API_PORT}/bot${botToken}/getFile?file_id=${file_id}`); if (!tgApiResponse.ok) { const errorData = await tgApiResponse.json().catch(() => ({ description: "Unknown error from Telegram API" })); return res.status(tgApiResponse.status).json({ error: `Telegram API error: ${errorData.description || tgApiResponse.statusText}` }); } const fileInfo = await tgApiResponse.json(); if (!fileInfo.ok || !fileInfo.result || !fileInfo.result.file_path) { return res.status(404).json({ error: 'File not found or path not available via Telegram API.', details: fileInfo }); } const filePathOnDisk = fileInfo.result.file_path; const resolvedDataDir = path.resolve(TELEGRAM_DATA_DIR); const resolvedFilePathOnDisk = path.resolve(filePathOnDisk); if (!resolvedFilePathOnDisk.startsWith(resolvedDataDir)) { console.error(`[Security] Attempt to access file outside TELEGRAM_DATA_DIR. Requested: ${filePathOnDisk}, Allowed base: ${resolvedDataDir}`); return res.status(403).send('Forbidden: File path is outside allowed directory.'); } if (fssync.existsSync(resolvedFilePathOnDisk) && fssync.statSync(resolvedFilePathOnDisk).isFile()) { res.sendFile(resolvedFilePathOnDisk, { headers: { 'Content-Disposition': `attachment; filename="${path.basename(fileInfo.result.file_path)}"` } }, err => { if (err && !res.headersSent) res.status(500).send('Error sending file.'); }); } else { res.status(404).send('File not found on disk.'); } } catch (error) { console.error(`Error in /:botToken/downloadFile for file_id ${file_id}:`, error); res.status(500).json({ error: 'Server error during download process.', details: error.message }); } }); fileRouter.get('/:filepath(*)', (req, res) => { const relativePath = req.params.filepath; if (!relativePath || relativePath.includes('..')) return res.status(400).send('Invalid file path.'); const absoluteRequestedPath = path.normalize(path.join(TELEGRAM_DATA_DIR, relativePath)); if (!path.resolve(absoluteRequestedPath).startsWith(path.resolve(TELEGRAM_DATA_DIR))) { return res.status(403).send('Forbidden: Path traversal attempt detected.'); } if (fssync.existsSync(absoluteRequestedPath) && fssync.statSync(absoluteRequestedPath).isFile()) { res.sendFile(absoluteRequestedPath, err => { if (err && !res.headersSent) res.status(500).send('Error sending file.'); }); } else res.status(404).send('File not found.'); }); app.use('/file', fileRouter); app.use(PROXY_TELEGRAM_PATH_PREFIX, createProxyMiddleware({ target: `http://localhost:${INTERNAL_TELEGRAM_API_PORT}`, changeOrigin: true, pathRewrite: (pathValue, req) => { return pathValue.startsWith(PROXY_TELEGRAM_PATH_PREFIX) ? pathValue.substring(PROXY_TELEGRAM_PATH_PREFIX.length) : pathValue; }, onProxyReq: (proxyReq, req, res) => { console.log(`[Proxy] Request to Telegram API: ${req.method} ${proxyReq.path}`); }, onError: (err, req, res) => { console.error('[Proxy] Error:', err); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json' }); } res.end(JSON.stringify({ error: 'Proxy Error', message: err.message })); } })); app.get('/', (req, res) => { const spaceHost = process.env.SPACE_HOST; const baseUrl = req.protocol + '://' + (spaceHost || req.get('host')); // Placeholders without angle brackets for better rendering if HTML escaping is tricky const botTokenPlaceholder = "YOUR_BOT_TOKEN"; const fileIdPlaceholder = "FILE_ID"; const methodPlaceholder = "METHOD_NAME"; const categoryPlaceholder = "CATEGORY"; const filenamePlaceholder = "FILENAME"; const relativeFilePathWithTokenPlaceholder = `bot${botTokenPlaceholder}/${categoryPlaceholder}/${filenamePlaceholder}`; const linkExpiryPlaceholder = "HOURS"; const encryptedPayloadPlaceholder = "ENCRYPTED_PAYLOAD"; const htmlDoc = ` Telegram Bot API Proxy & Tools Documentation

Прокси Telegram Bot API с расширенными инструментами

Этот Space запускает локальный сервер Telegram Bot API и предоставляет многофункциональное Node.js приложение-прокси с дополнительными инструментами.

Базовый URL вашего Space: ${baseUrl}

1. Основные возможности

  • "Чистое" проксирование Telegram Bot API (эндпоинт ${PROXY_TELEGRAM_PATH_PREFIX}/).
  • Специальный эндпоинт /file/${botTokenPlaceholder}/getFile для получения стандартного ответа Telegram API, дополненного ссылками на скачивание/удаление.
  • Управление файлами: статистика, скачивание, удаление, листинг.
  • Зашифрованные ссылки с истечением срока действия.
  • Автоматическая очистка кэша (TTL).
  • Интеграция с GitHub Gist.

2. Прокси Telegram Bot API (Стандартный)

URL: ${baseUrl}${PROXY_TELEGRAM_PATH_PREFIX}/bot${botTokenPlaceholder}/${methodPlaceholder}
Описание: Используйте этот URL вместо стандартного api.telegram.org. Возвращает неизмененный ответ от Telegram API.

3. Расширенные инструменты и операции с файлами

3.1. Получение информации о файле с дополнительными ссылками

URL: ${baseUrl}/file/${botTokenPlaceholder}/getFile?file_id=${fileIdPlaceholder}
Метод: GET
Описание: Делает запрос к локальному Telegram API, получает стандартный ответ и дополняет его полями: direct_download_link_by_path, direct_download_link_by_id, delete_link_by_path, delete_link_by_id, encrypted_download_link_by_path, encrypted_download_link_by_id.
Управление сроком действия зашифрованных ссылок: Добавьте параметр ?link_expiry_hours=${linkExpiryPlaceholder} к URL (${linkExpiryPlaceholder} - часы, -1 для бессрочной). По умолчанию: ${DEFAULT_LINK_EXPIRY_HOURS} ч.

3.2. Статистика

URL: ${baseUrl}/stats
Метод: GET
Параметры Query: run_ttl_now=true (опционально) - запустить очистку.
Ответ: JSON со статистикой.

3.3. Другие операции с файлами (базовый путь: ${baseUrl}/file)

ОперацияURLМетодОписание
Список файлов бота/list?token=${botTokenPlaceholder}GETJSON со списком файлов и их размерами.
Скачать по ID/${botTokenPlaceholder}/downloadFile?file_id=${fileIdPlaceholder}GETФайл для скачивания.
Скачать по пути/${relativeFilePathWithTokenPlaceholder}GETФайл для скачивания.
Скачать по зашифр. ссылке/downloadEncrypted?payload=${encryptedPayloadPlaceholder}GETФайл, если ссылка действительна.
Удалить по пути/deleteFile?file=${relativeFilePathWithTokenPlaceholder}GETJSON с результатом.
Удалить по ID/${botTokenPlaceholder}/deleteById?file_id=${fileIdPlaceholder}GETJSON с результатом.

Замечание о Gist: Если настроено, информация о Space обновляется в вашем Gist (файл hf_space_telegram_bot_api_proxy_info.json).

Шифрование ссылок: ${encryptionKeyBuffer ? "Включено" : "Отключено (LINK_ENCRYPTION_KEY не установлен)"}.

Telegram Bot API Proxy & Tools

This Space runs a local Telegram Bot API server and provides a feature-rich Node.js proxy application with additional tools.

Your Space Base URL: ${baseUrl}

1. Core Features

  • "Clean" Telegram Bot API Proxying (${PROXY_TELEGRAM_PATH_PREFIX}/ endpoint).
  • Dedicated endpoint /file/${botTokenPlaceholder}/getFile to get standard Telegram API response augmented with download/delete links.
  • File Management: statistics, download, delete, list.
  • Encrypted links with expiry.
  • Automatic Cache Cleanup (TTL).
  • GitHub Gist Integration.

2. Telegram Bot API Proxy (Standard)

URL: ${baseUrl}${PROXY_TELEGRAM_PATH_PREFIX}/bot${botTokenPlaceholder}/${methodPlaceholder}
Description: Use this URL instead of the standard api.telegram.org. Returns the unmodified response from the Telegram API.

3. Enhanced Tools & File Operations

3.1. Get File Info with Augmented Links

URL: ${baseUrl}/file/${botTokenPlaceholder}/getFile?file_id=${fileIdPlaceholder}
Method: GET
Description: Makes a request to the local Telegram API, gets the standard response, and augments it with: direct_download_link_by_path, direct_download_link_by_id, delete_link_by_path, delete_link_by_id, encrypted_download_link_by_path, encrypted_download_link_by_id.
Encrypted Link Expiry Management: Add ?link_expiry_hours=${linkExpiryPlaceholder} to the URL (${linkExpiryPlaceholder} is hours, -1 for indefinite). Default: ${DEFAULT_LINK_EXPIRY_HOURS}h.

3.2. Statistics

URL: ${baseUrl}/stats
Method: GET
Query Parameters: run_ttl_now=true (optional) - trigger cleanup.
Response: JSON with statistics.

3.3. Other File Operations (base path: ${baseUrl}/file)

OperationURLMethodDescription
List Bot Files/list?token=${botTokenPlaceholder}GETJSON list of files and their sizes.
Download by ID/${botTokenPlaceholder}/downloadFile?file_id=${fileIdPlaceholder}GETThe file for download.
Download by Path/${relativeFilePathWithTokenPlaceholder}GETThe file for download.
Download via Encrypted Link/downloadEncrypted?payload=${encryptedPayloadPlaceholder}GETFile, if link is valid.
Delete by Path/deleteFile?file=${relativeFilePathWithTokenPlaceholder}GETJSON with result.
Delete by ID/${botTokenPlaceholder}/deleteById?file_id=${fileIdPlaceholder}GETJSON with result.

Gist Note: If configured, Space info is updated in your Gist (file hf_space_telegram_bot_api_proxy_info.json).

Link Encryption: ${encryptionKeyBuffer ? "Enabled" : "Disabled (LINK_ENCRYPTION_KEY not set)"}.

`; res.send(htmlDoc); }); app.listen(APP_PORT, () => { console.log(`Main app (Proxy & Tools) listening on port ${APP_PORT}`); console.log(`Telegram Bot API (internal) should be on port ${INTERNAL_TELEGRAM_API_PORT}`); console.log(`Statistics available at /stats`); console.log(`File operations available under /file/`); console.log(`Telegram API (standard) proxied from ${PROXY_TELEGRAM_PATH_PREFIX}/`); console.log(`Augmented file info via /file/:token/getFile`); console.log(`Default link expiry: ${DEFAULT_LINK_EXPIRY_HOURS} hours (-1 for indefinite)`); updateEnvGistInGithub().catch(console.error); if (FILES_TTL_HOURS > 0) { const ttlIntervalMs = FILES_TTL_HOURS * 60 * 60 * 1000; console.log(`[TTL] Scheduling cleanup every ${FILES_TTL_HOURS} hours. First run in 5 minutes.`); setTimeout(() => { cleanupOldFiles(TELEGRAM_DATA_DIR, FILES_TTL_HOURS).catch(console.error); setInterval(() => cleanupOldFiles(TELEGRAM_DATA_DIR, FILES_TTL_HOURS).catch(console.error), ttlIntervalMs); }, 5 * 60 * 1000); } }); function gracefulShutdown() { console.log('Received shutdown signal. Exiting...'); process.exit(0); } process.on('SIGINT', gracefulShutdown); process.on('SIGTERM', gracefulShutdown);