diff --git a/backend/bot.js b/backend/bot.js index 03fc3c9..f01c91c 100644 --- a/backend/bot.js +++ b/backend/bot.js @@ -1,7 +1,8 @@ -// Telegram Bot для отправки изображений в ЛС +// Telegram Bot для отправки медиа (изображений/видео) в ЛС const axios = require('axios'); const FormData = require('form-data'); const config = require('./config'); +const path = require('path'); if (!config.telegramBotToken) { console.warn('⚠️ TELEGRAM_BOT_TOKEN не установлен! Функция отправки фото в Telegram недоступна.'); @@ -11,15 +12,39 @@ const TELEGRAM_API = config.telegramBotToken ? `https://api.telegram.org/bot${config.telegramBotToken}` : null; +// Декодировать HTML entities (например, /) +function decodeHtmlEntities(str = '') { + if (!str || typeof str !== 'string') { + return str; + } + + return str + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(///g, '/') + .replace(///g, '/'); +} + +const VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.mov', '.m4v', '.avi', '.mkv']); + // Получить оригинальный URL из прокси URL function getOriginalUrl(proxyUrl) { - if (!proxyUrl || !proxyUrl.startsWith('/api/search/proxy/')) { + if (!proxyUrl) { return proxyUrl; } + + const cleanUrl = decodeHtmlEntities(proxyUrl); + + if (!cleanUrl.startsWith('/api/search/proxy/')) { + return cleanUrl; + } try { // Извлекаем encodedUrl из прокси URL - const encodedUrl = proxyUrl.replace('/api/search/proxy/', ''); + const encodedUrl = cleanUrl.replace('/api/search/proxy/', ''); // Декодируем base64 const originalUrl = Buffer.from(encodedUrl, 'base64').toString('utf-8'); return originalUrl; @@ -29,7 +54,24 @@ function getOriginalUrl(proxyUrl) { } } -// Отправить одно фото пользователю +function looksLikeVideo(url = '', contentType = '') { + if (contentType && contentType.toLowerCase().startsWith('video/')) { + return true; + } + + let candidate = url; + try { + const parsed = new URL(url); + candidate = parsed.pathname; + } catch (e) { + // ignore + } + + const ext = path.extname((candidate || '').split('?')[0]).toLowerCase(); + return VIDEO_EXTENSIONS.has(ext); +} + +// Отправить медиа (фото/видео) пользователю async function sendPhotoToUser(userId, photoUrl, caption) { if (!TELEGRAM_API) { throw new Error('TELEGRAM_BOT_TOKEN не установлен'); @@ -51,42 +93,81 @@ async function sendPhotoToUser(userId, photoUrl, caption) { finalPhotoUrl.includes('gelbooru.com') || finalPhotoUrl.includes('nakama.glpshchn.ru'); + const isVideo = looksLikeVideo(finalPhotoUrl); + if (isPublicUrl) { - // Используем публичный URL напрямую - const response = await axios.post(`${TELEGRAM_API}/sendPhoto`, { + const payload = { chat_id: userId, - photo: finalPhotoUrl, caption: caption || '', parse_mode: 'HTML' - }); - - return response.data; - } else { - // Если URL не публичный, скачиваем изображение и отправляем как файл - const imageResponse = await axios.get(finalPhotoUrl, { - responseType: 'stream', - timeout: 30000 - }); - - const form = new FormData(); - form.append('chat_id', userId); - form.append('photo', imageResponse.data, { - filename: 'image.jpg', - contentType: imageResponse.headers['content-type'] || 'image/jpeg' - }); - if (caption) { - form.append('caption', caption); + }; + + if (isVideo) { + const response = await axios.post(`${TELEGRAM_API}/sendVideo`, { + ...payload, + video: finalPhotoUrl, + supports_streaming: true + }); + return response.data; } - form.append('parse_mode', 'HTML'); - - const response = await axios.post(`${TELEGRAM_API}/sendPhoto`, form, { - headers: form.getHeaders() + + const response = await axios.post(`${TELEGRAM_API}/sendPhoto`, { + ...payload, + photo: finalPhotoUrl }); - return response.data; } + + // Если URL не публичный, скачиваем файл и отправляем как multipart + const fileResponse = await axios.get(finalPhotoUrl, { + responseType: 'stream', + timeout: 30000 + }); + + const contentType = fileResponse.headers['content-type'] || ''; + const inferredVideo = isVideo || looksLikeVideo(finalPhotoUrl, contentType); + const form = new FormData(); + form.append('chat_id', userId); + + const endpoint = inferredVideo ? 'sendVideo' : 'sendPhoto'; + const fieldName = inferredVideo ? 'video' : 'photo'; + const defaultExt = inferredVideo ? '.mp4' : '.jpg'; + let filename = `file${defaultExt}`; + + try { + const parsed = new URL(finalPhotoUrl); + const ext = path.extname(parsed.pathname || ''); + if (ext) { + filename = `file${ext}`; + } + } catch (e) { + const ext = path.extname(finalPhotoUrl); + if (ext) { + filename = `file${ext}`; + } + } + + form.append(fieldName, fileResponse.data, { + filename, + contentType: contentType || (inferredVideo ? 'video/mp4' : 'image/jpeg') + }); + + if (caption) { + form.append('caption', caption); + } + form.append('parse_mode', 'HTML'); + + if (inferredVideo) { + form.append('supports_streaming', 'true'); + } + + const response = await axios.post(`${TELEGRAM_API}/${endpoint}`, form, { + headers: form.getHeaders() + }); + + return response.data; } catch (error) { - console.error('Ошибка отправки фото:', error.response?.data || error.message); + console.error('Ошибка отправки медиа:', error.response?.data || error.message); throw error; } } @@ -125,23 +206,27 @@ async function sendPhotosToUser(userId, photos) { photoUrl.includes('gelbooru.com') || photoUrl.includes('nakama.glpshchn.ru'); + const isVideo = looksLikeVideo(photoUrl, photo.contentType); + if (isPublicUrl) { // Используем публичный URL напрямую media.push({ - type: 'photo', + type: isVideo ? 'video' : 'photo', media: photoUrl, caption: index === 0 ? `Из NakamaHost\n${batch.length} фото` : undefined, - parse_mode: 'HTML' + parse_mode: 'HTML', + ...(isVideo ? { supports_streaming: true } : {}) }); } else { // Для непубличных URL нужно скачать изображение // Но в sendMediaGroup нельзя смешивать URL и файлы // Поэтому используем URL как есть (Telegram попробует загрузить) media.push({ - type: 'photo', + type: isVideo ? 'video' : 'photo', media: photoUrl, caption: index === 0 ? `Из NakamaHost\n${batch.length} фото` : undefined, - parse_mode: 'HTML' + parse_mode: 'HTML', + ...(isVideo ? { supports_streaming: true } : {}) }); } } @@ -156,7 +241,7 @@ async function sendPhotosToUser(userId, photos) { return results; } catch (error) { - console.error('Ошибка отправки фото группой:', error.response?.data || error.message); + console.error('Ошибка отправки медиа группой:', error.response?.data || error.message); throw error; } } diff --git a/backend/bots/serverMonitor.js b/backend/bots/serverMonitor.js index 15d69cf..dc57a2b 100644 --- a/backend/bots/serverMonitor.js +++ b/backend/bots/serverMonitor.js @@ -361,18 +361,22 @@ const sendChannelMediaGroup = async (files, caption) => { const chatId = config.moderationChannelUsername || '@reichenbfurry'; const form = new FormData(); - const media = files.map((file, index) => ({ - type: 'photo', - media: `attach://file${index}`, - ...(index === 0 ? { caption: `${caption}${ERROR_SUPPORT_SUFFIX}`, parse_mode: 'HTML' } : {}) - })); + const media = files.map((file, index) => { + const isVideo = file.mimetype && file.mimetype.startsWith('video/'); + return { + type: isVideo ? 'video' : 'photo', + media: `attach://file${index}`, + ...(index === 0 ? { caption: `${caption}${ERROR_SUPPORT_SUFFIX}`, parse_mode: 'HTML' } : {}), + ...(isVideo ? { supports_streaming: true } : {}) + }; + }); form.append('chat_id', chatId); form.append('media', JSON.stringify(media)); files.forEach((file, index) => { form.append(`file${index}`, fs.createReadStream(file.path), { - filename: file.filename || `image${index}.jpg` + filename: file.originalname || file.filename || `media${index}` }); }); diff --git a/moderation/frontend/src/App.jsx b/moderation/frontend/src/App.jsx index 585a4a7..ac2127f 100644 --- a/moderation/frontend/src/App.jsx +++ b/moderation/frontend/src/App.jsx @@ -568,8 +568,8 @@ export default function App() { {publishState.files.length > 0 && (