diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 954fae7..a8bb49d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,7 +144,7 @@ jobs: run: bin/rails db:test:prepare test:system - name: Keep screenshots from failed system tests - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: failure() with: name: screenshots diff --git a/.gitignore b/.gitignore index fbcab40..0546beb 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ # Ignore key files for decrypting credentials and more. /config/*.key +/DEPLOY.md \ No newline at end of file diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..3018451 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,71 @@ +# Деплой на сервер (Docker Compose) + +Ниже — самый простой способ задеплоить этот бот на VPS (Ubuntu/Debian) через Docker. + +## 1) Подготовка сервера + +Установи Docker и Compose: + +```bash +sudo apt-get update +sudo apt-get install -y docker.io docker-compose-plugin +sudo usermod -aG docker $USER +newgrp docker +``` + +## 2) Подготовка проекта на сервере + +Склонируй репозиторий: + +```bash +git clone video-link-bot +cd video-link-bot +``` + +## 3) Секреты (env) + +Создай файл `.env` рядом с `docker-compose.prod.yml`: + +```bash +cat > .env <<'EOF' +TELEGRAM_BOT_TOKEN=__PUT_TOKEN_HERE__ +DATABASE_PASSWORD=__PUT_DB_PASSWORD_HERE__ +RAILS_MASTER_KEY=__PUT_MASTER_KEY_HERE__ +EOF +``` + +Где взять `RAILS_MASTER_KEY`: +- локально: `cat config/master.key` (НЕ коммить в git) + +## 4) Запуск + +Собрать и поднять сервисы: + +```bash +docker compose -f docker-compose.prod.yml --env-file .env up -d --build +``` + +Проверить логи: + +```bash +docker compose -f docker-compose.prod.yml --env-file .env logs -f --tail=200 bot +docker compose -f docker-compose.prod.yml --env-file .env logs -f --tail=200 app +``` + +Остановить: + +```bash +docker compose -f docker-compose.prod.yml --env-file .env down +``` + +## Что будет запущено + +- `db` — Postgres +- `app` — Rails (порт 80) + Solid Queue внутри Puma (`SOLID_QUEUE_IN_PUMA=true`) +- `bot` — отдельный процесс, который запускает `rake bot:start` (long polling) + +## Примечания + +1) Для TikTok метаданных используется `yt-dlp` — он устанавливается внутри Docker-образа. +2) Если хочешь HTTPS и домен — проще всего поставить перед контейнером Caddy/Nginx или перейти на Kamal. + diff --git a/Dockerfile b/Dockerfile index ed7311b..0bcecb0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ WORKDIR /rails # Install base packages RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client yt-dlp && \ ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives diff --git a/app/bots/bot_messages.rb b/app/bots/bot_messages.rb new file mode 100644 index 0000000..a72de21 --- /dev/null +++ b/app/bots/bot_messages.rb @@ -0,0 +1,96 @@ +# Тексты сообщений бота — вынесены в отдельный модуль для удобства правок +module BotMessages + WELCOME = <<~TEXT.freeze + 🎬 *Video Downloader Bot* + + Я могу помочь вам скачать видео с популярных платформ! + + 📋 *Поддерживаемые платформы:* + • YouTube 🎥 + • Instagram 📸 + • TikTok 🎵 + + ✨ *Просто отправьте мне ссылку на видео!* + + 📌 *Примеры ссылок:* + • https://youtube.com/watch?v=... + • https://instagram.com/p/... + • https://tiktok.com/@.../video/... + + 📝 *Доступные команды:* + /help - Справка + /supported - Поддерживаемые платформы + /stats - Статистика + TEXT + + HELP = <<~TEXT.freeze + ℹ️ *Как пользоваться ботом:* + + 1. *Отправьте ссылку* на видео с YouTube, Instagram или TikTok + 2. Бот *проанализирует* ссылку + 3. Получите *информацию о видео* и опцию скачивания + + ⚠️ *Ограничения:* + • Максимальный размер файла: 50MB + • До 10 запросов в час + • Некоторые видео могут быть недоступны + + 🔒 *Конфиденциальность:* + Ваши ссылки хранятся временно и удаляются через 24 часа. + TEXT + + SUPPORTED = <<~TEXT.freeze + 📋 *Подробно о поддерживаемых платформах:* + + *YouTube* 🎥 + • Поддерживаются обычные видео + • Короткие видео (Shorts) + • Плейлисты (первое видео) + + *Instagram* 📸 + • Посты с видео (Reels, IGTV) + • Истории (если публичные) + • Не поддерживаются: приватные аккаунты + + *TikTok* 🎵 + • Все публичные видео + • С музыкой и эффектами + • Без водяных знаков (где возможно) + + 🚀 *Просто отправьте ссылку!* + TEXT + + UNKNOWN_COMMAND = "❌ Неизвестная команда. Используйте /help" + ERROR_GENERIC = "⚠️ Произошла ошибка. Попробуйте позже." + PROCESSING = "⏳ Обрабатываю ссылку...\n\n🔗 %{url}" + + def self.stats(requests) + <<~TEXT + 📊 *Ваша статистика:* + + Всего запросов: #{requests.count} + Успешно: #{requests.completed.count} + В обработке: #{requests.processing.count} + Не удалось: #{requests.failed.count} + + ⏰ *Последние запросы:* + #{recent_requests_summary(requests)} + TEXT + end + + def self.recent_requests_summary(requests) + recent = requests.order(created_at: :desc).limit(5) + recent.map do |req| + "• #{req.platform_name}: #{req.status_name} (#{time_ago(req.created_at)})" + end.join("\n") + end + + def self.time_ago(time) + minutes = ((Time.current - time) / 60).to_i + if minutes < 60 + "#{minutes} мин назад" + else + "#{(minutes / 60).to_i} ч назад" + end + end +end diff --git a/app/bots/video_downloader_bot.rb b/app/bots/video_downloader_bot.rb index d630c13..71724fd 100644 --- a/app/bots/video_downloader_bot.rb +++ b/app/bots/video_downloader_bot.rb @@ -2,19 +2,17 @@ class VideoDownloaderBot TOKEN = ENV['TELEGRAM_BOT_TOKEN'] - + def run puts "🤖 Запуск бота Video Downloader..." - + Telegram::Bot::Client.run(TOKEN) do |bot| - bot.listen do |message| - handle_message(bot, message) - end + bot.listen { |message| handle_message(bot, message) } end end - + private - + def handle_message(bot, message) case message when Telegram::Bot::Types::Message @@ -22,29 +20,24 @@ def handle_message(bot, message) when Telegram::Bot::Types::CallbackQuery handle_callback(bot, message) end - rescue => e + rescue StandardError => e puts "❌ Ошибка: #{e.message}" bot.api.send_message( chat_id: message.chat.id, - text: "⚠️ Произошла ошибка. Попробуйте позже." + text: BotMessages::ERROR_GENERIC ) end - + def handle_text_message(bot, message) - chat_id = message.chat.id - if message.text.start_with?('/') - p '1' handle_command(bot, message) elsif valid_url?(message.text) - p '2' handle_url(bot, message) else - p '3' - send_welcome_message(bot, chat_id) + send_welcome_message(bot, message.chat.id) end end - + def handle_command(bot, message) case message.text when '/start' @@ -54,178 +47,81 @@ def handle_command(bot, message) when '/supported' send_supported_platforms(bot, message.chat.id) when '/stats' - send_stats(bot, message.chat.id, message.from.id) + send_stats(bot, message.chat.id) else bot.api.send_message( chat_id: message.chat.id, - text: "❌ Неизвестная команда. Используйте /help" + text: BotMessages::UNKNOWN_COMMAND ) end end - + def handle_url(bot, message) chat_id = message.chat.id url = message.text.strip - - p 'Сохраняем запрос' + request = VideoRequest.create!( url: url, telegram_chat_id: chat_id, telegram_message_id: message.message_id, - status: :processing + status: VideoRequest::STATUSES[:processing] ) - - p 'Отправляем сообщение о начале обработки' + processing_msg = bot.api.send_message( chat_id: chat_id, - text: "⏳ Обрабатываю ссылку...\n\n🔗 #{url}", + text: format(BotMessages::PROCESSING, url: url), parse_mode: 'HTML' ) - - p 'Обрабатываем в фоне' - p request.id - p processing_msg['result']['message_id'] + VideoProcessorJob.perform_later(request.id, processing_msg['result']['message_id']) end - + def handle_callback(bot, callback) - # Обработка callback кнопок case callback.data when /^download_(.+)$/ - request_id = $1 - download_video(bot, callback, request_id) + download_video(bot, callback, Regexp.last_match(1)) when 'more_info' # show_more_info(bot, callback) end end - + def send_welcome_message(bot, chat_id) - text = <<~TEXT - 🎬 *Video Downloader Bot* - - Я могу помочь вам скачать видео с популярных платформ! - - 📋 *Поддерживаемые платформы:* - • YouTube 🎥 - • Instagram 📸 - • TikTok 🎵 - - ✨ *Просто отправьте мне ссылку на видео!* - - 📌 *Примеры ссылок:* - • https://youtube.com/watch?v=... - • https://instagram.com/p/... - • https://tiktok.com/@.../video/... - - 📝 *Доступные команды:* - /help - Справка - /supported - Поддерживаемые платформы - /stats - Статистика - TEXT - bot.api.send_message( chat_id: chat_id, - text: text, + text: BotMessages::WELCOME, parse_mode: 'Markdown' ) end - + def send_help_message(bot, chat_id) - text = <<~TEXT - ℹ️ *Как пользоваться ботом:* - - 1. *Отправьте ссылку* на видео с YouTube, Instagram или TikTok - 2. Бот *проанализирует* ссылку - 3. Получите *информацию о видео* и опцию скачивания - - ⚠️ *Ограничения:* - • Максимальный размер файла: 50MB - • До 10 запросов в час - • Некоторые видео могут быть недоступны - - 🔒 *Конфиденциальность:* - Ваши ссылки хранятся временно и удаляются через 24 часа. - TEXT - bot.api.send_message( chat_id: chat_id, - text: text, + text: BotMessages::HELP, parse_mode: 'Markdown' ) end - + def send_supported_platforms(bot, chat_id) - text = <<~TEXT - 📋 *Подробно о поддерживаемых платформах:* - - *YouTube* 🎥 - • Поддерживаются обычные видео - • Короткие видео (Shorts) - • Плейлисты (первое видео) - - *Instagram* 📸 - • Посты с видео (Reels, IGTV) - • Истории (если публичные) - • Не поддерживаются: приватные аккаунты - - *TikTok* 🎵 - • Все публичные видео - • С музыкой и эффектами - • Без водяных знаков (где возможно) - - 🚀 *Просто отправьте ссылку!* - TEXT - bot.api.send_message( chat_id: chat_id, - text: text, + text: BotMessages::SUPPORTED, parse_mode: 'Markdown' ) end - - def send_stats(bot, chat_id, user_id) + + def send_stats(bot, chat_id, _user_id) requests = VideoRequest.where(telegram_chat_id: chat_id.to_s) - - text = <<~TEXT - 📊 *Ваша статистика:* - - Всего запросов: #{requests.count} - Успешно: #{requests.completed.count} - В обработке: #{requests.processing.count} - Не удалось: #{requests.failed.count} - - ⏰ *Последние запросы:* - #{recent_requests_summary(requests)} - TEXT - bot.api.send_message( chat_id: chat_id, - text: text, + text: BotMessages.stats(requests), parse_mode: 'Markdown' ) end - - def recent_requests_summary(requests) - recent = requests.order(created_at: :desc).limit(5) - - recent.map do |req| - "• #{req.platform_name}: #{req.status} (#{time_ago(req.created_at)})" - end.join("\n") - end - - def time_ago(time) - minutes = ((Time.current - time) / 60).to_i - if minutes < 60 - "#{minutes} мин назад" - else - "#{(minutes / 60).to_i} ч назад" - end - end - + def valid_url?(text) uri = URI.parse(text) uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) rescue URI::InvalidURIError false end -end \ No newline at end of file +end diff --git a/app/jobs/video_processor_job.rb b/app/jobs/video_processor_job.rb index e1ccc42..4e81fc6 100644 --- a/app/jobs/video_processor_job.rb +++ b/app/jobs/video_processor_job.rb @@ -1,20 +1,23 @@ class VideoProcessorJob < ActiveJob::Base queue_as :default - + def perform(request_id, processing_message_id = nil) request = VideoRequest.find(request_id) - + request.update!(status: :processing) - + video_info = VideoDownloader.get_video_info(request.url) - + if video_info[:error] request.update!( status: :failed, error_message: video_info[:error] ) - - send_error_message(request, video_info[:error], processing_message_id) + TelegramResultSender.send_error( + request: request, + error: video_info[:error], + processing_message_id: processing_message_id + ) else request.update!( status: :completed, @@ -23,220 +26,13 @@ def perform(request_id, processing_message_id = nil) duration: video_info[:duration], thumbnail_url: video_info[:thumbnail], video_url: video_info[:video_url], - platform: video_info[:platform].to_s - ) - - send_video_info(request, video_info, processing_message_id) - end - end - - private - - def send_error_message(request, error, processing_message_id) - bot = Telegram::Bot::Client.new(ENV['TELEGRAM_BOT_TOKEN']) - - if processing_message_id - bot.api.delete_message( - chat_id: request.telegram_chat_id, - message_id: processing_message_id - ) - end - - text = <<~TEXT - ❌ *Не удалось обработать ссылку* - - 🔗 #{request.url} - - 💡 *Причина:* #{error} - TEXT - - bot.api.send_message( - chat_id: request.telegram_chat_id, - text: text, - parse_mode: 'Markdown' - ) - end - - def send_video_info(request, video_info, processing_message_id) - bot = Telegram::Bot::Client.new(ENV['TELEGRAM_BOT_TOKEN']) - - # if processing_message_id - # bot.api.delete_message( - # chat_id: request.telegram_chat_id, - # message_id: processing_message_id - # ) - # end - - text = <<~TEXT - ✅ *Видео найдено!* - - 📝 *Информация:* - 🏷️ *Название:* #{video_info[:title] || 'Не указано'} - 👤 *Автор:* #{video_info[:author] || 'Неизвестно'} - ⏱️ *Длительность:* #{format_duration(video_info[:duration])} - 📊 *Платформа:* #{platform_emoji(video_info[:platform])} #{video_info[:platform].to_s.capitalize} - - 🔗 *Исходная ссылка:* #{request.url} - TEXT - - if video_info[:video_url] - if video_info[:platform].to_s == 'instagram' - send_instagram_video(bot, request, video_info) - else - text += "\n\n⬇️ *Ссылка для скачивания:* #{video_info[:video_url]}" - end - end - - if video_info[:platform].to_s == 'instagram' - send_instagram_link(bot, request, video_info) - else - bot.api.send_message( - chat_id: request.telegram_chat_id, - text: 'text', - parse_mode: 'Markdown', - reply_markup: download_keyboard(request.id, video_info) - ) - end - - # if video_info[:thumbnail] - # begin - # bot.api.send_photo( - # chat_id: request.telegram_chat_id, - # photo: video_info[:thumbnail], - # caption: "📸 Превью видео" - # ) - # rescue - # end - # end - end - - def send_instagram_video(bot, request, video_info) - begin - # Пытаемся отправить видео файлом - # Telegram поддерживает отправку видео по URL - - caption = "📸 *Instagram видео*\n\n" - caption += "🎬 *#{video_info[:title]}*\n" if video_info[:title] - caption += "👤 #{video_info[:author]}\n" if video_info[:author] - caption += "\n✅ Видео загружено в Telegram" - - bot.api.send_video( - chat_id: request.telegram_chat_id, - video: video_info[:video_url], # Прямая ссылка на видео - caption: caption, - parse_mode: 'Markdown', - supports_streaming: true # Позволяет потоковое воспроизведение + platform: (VideoRequest::PLATFORMS[video_info[:platform].to_sym] || VideoRequest::PLATFORMS[:unknown]) ) - - rescue Telegram::Bot::Exceptions::ResponseError => e - # Если не получается отправить видео, отправляем как документ - puts "Не удалось отправить видео: #{e.message}" - puts video_info - send_instagram_as_document(bot, request, video_info) - end - end - - def send_instagram_as_document(bot, request, video_info) - caption = "📸 *Instagram видео*\n\n" - caption += "🎬 *#{video_info[:title]}*\n" if video_info[:title] - caption += "👤 #{video_info[:author]}\n" if video_info[:author] - - begin - bot.api.send_document( - chat_id: request.telegram_chat_id, - document: video_info[:video_url], - caption: caption, - parse_mode: 'Markdown' + TelegramResultSender.send_success( + request: request, + video_info: video_info, + processing_message_id: processing_message_id ) - rescue => e - # Если и документ не отправляется, отправляем ссылку - puts "Не удалось отправить документ: #{e.message}" - send_instagram_link(bot, request, video_info) - end - end - - def send_instagram_link(bot, request, video_info) - text = "📸 *Instagram видео*\n\n" - - if video_info[:title] - text += "🎬 *#{video_info[:title]}*\n" - end - - if video_info[:author] - text += "👤 *#{video_info[:author]}*\n\n" - end - - text += "🔗 *Ссылка:* #{request.url}\n\n" - text += "📱 *Как смотреть:*\n" - text += "• Нажмите на ссылку выше\n" - text += "• Instagram откроет видео в приложении\n" - text += "• Или откройте в браузере" - - # Отправляем превью если есть - if video_info[:thumbnail] - begin - bot.api.send_photo( - chat_id: request.telegram_chat_id, - photo: video_info[:thumbnail], - caption: text, - parse_mode: 'Markdown' - ) - return - rescue => e - puts "Не удалось отправить фото: #{e.message}" - end - end - - bot.api.send_message( - chat_id: request.telegram_chat_id, - text: text, - parse_mode: 'Markdown', - disable_web_page_preview: false # Разрешаем превью - ) - end - - def download_keyboard(request_id, video_info) - buttons = [] - - if video_info[:video_url] - buttons << [ - Telegram::Bot::Types::InlineKeyboardButton.new( - text: "⬇️ Скачать видео", - url: video_info[:video_url] - ) - ] - end - - buttons << [ - Telegram::Bot::Types::InlineKeyboardButton.new( - text: "ℹ️ Подробнее", - callback_data: "more_info" - ) - ] - - Telegram::Bot::Types::InlineKeyboardMarkup.new(inline_keyboard: buttons) - end - - def format_duration(seconds) - return "Неизвестно" unless seconds - - hours = seconds / 3600 - minutes = (seconds % 3600) / 60 - secs = seconds % 60 - - if hours > 0 - format("%02d:%02d:%02d", hours, minutes, secs) - else - format("%02d:%02d", minutes, secs) - end - end - - def platform_emoji(platform) - case platform.to_sym - when :youtube then '🎥' - when :instagram then '📸' - when :tiktok then '🎵' - else '🔗' end end -end \ No newline at end of file +end diff --git a/app/models/video_request.rb b/app/models/video_request.rb index 8471a7f..2a928b6 100644 --- a/app/models/video_request.rb +++ b/app/models/video_request.rb @@ -5,6 +5,10 @@ class VideoRequest < ActiveRecord::Base PLATFORMS = { youtube: 0, instagram: 1, tiktok: 2, unknown: 3 }.freeze validates :url, presence: true + + scope :completed, -> { where(status: STATUSES[:completed]) } + scope :processing, -> { where(status: STATUSES[:processing]) } + scope :failed, -> { where(status: STATUSES[:failed]) } before_validation :set_defaults @@ -47,4 +51,8 @@ def unknown? = platform == PLATFORMS[:unknown] def platform_name PLATFORMS.key(platform)&.to_s&.capitalize || 'Unknown' end + + def status_name + STATUSES.key(status)&.to_s&.capitalize || status.to_s + end end \ No newline at end of file diff --git a/app/services/instagram_video_processor.rb b/app/services/instagram_video_processor.rb index 369ac65..e969bce 100644 --- a/app/services/instagram_video_processor.rb +++ b/app/services/instagram_video_processor.rb @@ -1,12 +1,10 @@ class InstagramVideoProcessor class << self def get_video_for_telegram(url) - # Получаем информацию о посте video_info = get_instagram_info(url) return video_info if video_info[:error] - # Получаем прямую ссылку на видео video_url = decode_instagram_url(extract_video_url(video_info[:html], url)) if video_url @@ -16,9 +14,9 @@ def get_video_for_telegram(url) title: video_info[:title] || "Instagram Video", author: video_info[:author] || "Instagram", thumbnail: video_info[:thumbnail], - video_url: video_url, # Прямая ссылка на видео файл + video_url: video_url, is_video: true, - watch_url: url, # Оригинальная ссылка + watch_url: url, message: "Instagram видео готово к просмотру в Telegram" } else @@ -54,31 +52,7 @@ def get_instagram_info(url) { error: "Instagram ошибка: #{e.message}" } end - def extract_video_url(html, original_url) - # Парсим JSON данные Instagram - # Instagram хранит информацию в window.__additionalDataLoaded - - # Вариант 1: Ищем video_url в JSON - if html.include?('video_url') - match = html.match(/"video_url":"([^"]+)"/) - return match[1].gsub('\\u0026', '&') if match - end - - # Вариант 2: Ищем в структуре данных - if html.include?('"video_versions"') - match = html.match(/"video_versions":\[.*?"url":"([^"]+)"/m) - return match[1].gsub('\\u0026', '&') if match - end - - # Вариант 3: Используем сторонний сервис - # Например: https://igram.io/api/ - # Или: https://rapidapi.com/ (требует API ключ) - - nil - end - def decode_instagram_url(encoded_url) - # Убираем все escape-последовательности decoded = encoded_url .gsub('\\/', '/') # Убираем экранирование слешей .gsub('\\u0026', '&') # Декодируем & из unicode @@ -98,54 +72,32 @@ def fix_instagram_url(url) begin uri = URI.parse(url) - # Убираем стандартные порты if (uri.scheme == 'https' && uri.port == 443) || (uri.scheme == 'http' && uri.port == 80) uri.port = nil end - # Проверяем, что хост есть return nil unless uri.host - # Возвращаем исправленный URL uri.to_s rescue URI::InvalidURIError => e - # Пробуем простое исправление - fixed = url - .gsub(/:443/, '') # Убираем порт 443 - .gsub(/:80/, '') # Убираем порт 80 + fixed = url.gsub(/:443/, '').gsub(/:80/, '') - # Проверяем, что это валидный URL - if fixed =~ /\Ahttps?:\/\// - fixed - else - nil - end + fixed =~ /\Ahttps?:\/\// ? fixed : nil end end def extract_video_url(html, original_url) - # Ищем video_url в JSON if html.include?('video_url') - # Ищем все совпадения matches = html.scan(/"video_url":"([^"]+)"/) - - puts "Найдено #{matches.size} video_url в HTML" - - # Берем последнее совпадение (обычно это самое актуальное) + if matches.any? encoded_url = matches.last[0] - puts "Найден URL (закодированный): #{encoded_url[0..100]}..." - - decoded_url = decode_instagram_url(encoded_url) - puts "Декодированный URL: #{decoded_url[0..100]}..." if decoded_url - - return decoded_url + return decode_instagram_url(encoded_url) end end - # Пробуем другие методы поиска [ /"video_url":"([^"]+)"/, /"video_versions":\[.*?"url":"([^"]+)"/m, @@ -154,10 +106,7 @@ def extract_video_url(html, original_url) ].each do |pattern| match = html.match(pattern) if match - encoded_url = match[1] - puts "Найден по паттерну: #{encoded_url[0..100]}..." - - decoded_url = decode_instagram_url(encoded_url) + decoded_url = decode_instagram_url(match[1]) return decoded_url if decoded_url end end diff --git a/app/services/telegram_result_sender.rb b/app/services/telegram_result_sender.rb new file mode 100644 index 0000000..d7d2a28 --- /dev/null +++ b/app/services/telegram_result_sender.rb @@ -0,0 +1,272 @@ +# Отправка результатов обработки видео в Telegram +class TelegramResultSender + def self.send_error(request:, error:, processing_message_id: nil) + new.send_error(request, error, processing_message_id) + end + + def self.send_success(request:, video_info:, processing_message_id: nil) + new.send_success(request, video_info, processing_message_id) + end + + def send_error(request, error, processing_message_id) + if processing_message_id + bot.api.delete_message( + chat_id: request.telegram_chat_id, + message_id: processing_message_id + ) + end + + text = <<~TEXT + ❌ *Не удалось обработать ссылку* + + 🔗 #{escape_md(request.url)} + + 💡 *Причина:* #{escape_md(error)} + TEXT + + bot.api.send_message( + chat_id: request.telegram_chat_id, + text: text, + parse_mode: 'Markdown' + ) + end + + def send_success(request, video_info, processing_message_id) + if video_info[:platform] == :youtube + return send_youtube_mini_app(request.telegram_chat_id, video_info[:video_id]) + end + + text = build_video_info_text(request, video_info) + + if video_info[:video_url] && video_info[:platform] == :instagram + send_instagram_video(request, video_info) + elsif video_info[:video_url] && video_info[:platform] == :tiktok && direct_media_url?(video_info[:video_url]) + send_tik_tok_video(request, video_info) + else + text += "\n\n⬇️ *Ссылка для скачивания:* #{escape_md(video_info[:video_url])}" if video_info[:video_url] + end + + bot.api.send_message( + chat_id: request.telegram_chat_id, + text: text, + parse_mode: 'Markdown', + reply_markup: download_keyboard(request.id, video_info) + ) + end + + private + + # Экранирование символов, ломающих Telegram Markdown (* _ ` [ ) + def escape_md(str) + return '' if str.blank? + str.to_s.gsub(/([\\*_`\[\]])/, "\\\\\\1") + end + + def bot + @bot ||= Telegram::Bot::Client.new(ENV['TELEGRAM_BOT_TOKEN']) + end + + def build_video_info_text(request, video_info) + <<~TEXT + ✅ *Видео найдено!* + + 📝 *Информация:* + 🏷️ *Название:* #{escape_md(video_info[:title] || 'Не указано')} + 👤 *Автор:* #{escape_md(video_info[:author] || 'Неизвестно')} + ⏱️ *Длительность:* #{format_duration(video_info[:duration])} + 📊 *Платформа:* #{platform_emoji(video_info[:platform])} #{video_info[:platform].to_s.capitalize} + + 🔗 *Исходная ссылка:* #{escape_md(request.url)} + TEXT + end + + def send_instagram_video(request, video_info) + caption = "📸 *Instagram видео*\n\n" + caption += "🎬 *#{escape_md(video_info[:title])}*\n" if video_info[:title] + caption += "👤 #{escape_md(video_info[:author])}\n" if video_info[:author] + caption += "\n✅ Видео загружено в Telegram" + + bot.api.send_video( + chat_id: request.telegram_chat_id, + video: video_info[:video_url], + caption: caption, + parse_mode: 'Markdown', + supports_streaming: true + ) + rescue Telegram::Bot::Exceptions::ResponseError => e + Rails.logger.warn("Telegram send_video failed: #{e.message}") + send_instagram_as_document(request, video_info) + end + + def send_tik_tok_video(request, video_info) + caption = "🎵 *TikTok видео*\n\n" + caption += "🎬 *#{escape_md(video_info[:title])}*\n" if video_info[:title] + caption += "👤 #{escape_md(video_info[:author])}\n" if video_info[:author] + caption += "\n✅ Видео загружено в Telegram" + + # Сначала по URL; если Telegram не может скачать (403 и т.д.) — скачиваем сами и отправляем файлом + bot.api.send_video( + chat_id: request.telegram_chat_id, + video: video_info[:video_url], + caption: caption, + parse_mode: 'Markdown', + supports_streaming: true + ) + rescue Telegram::Bot::Exceptions::ResponseError => e + Rails.logger.warn("Telegram send_video (TikTok) failed: #{e.message}") + send_tiktok_video_as_file(request, video_info) + end + + def send_tiktok_video_as_file(request, video_info) + caption = "🎵 *TikTok видео*\n\n" + caption += "🎬 *#{escape_md(video_info[:title])}*\n" if video_info[:title] + caption += "👤 #{escape_md(video_info[:author])}\n" if video_info[:author] + caption += "\n✅ Видео загружено в Telegram" + + tempfile = download_tiktok_to_tempfile(video_info[:video_url], request.url) + return send_tik_tok_as_document(request, video_info) unless tempfile + + bot.api.send_video( + chat_id: request.telegram_chat_id, + video: Faraday::UploadIO.new(tempfile.path, 'video/mp4'), + caption: caption, + parse_mode: 'Markdown', + supports_streaming: true + ) + ensure + tempfile&.close + tempfile&.unlink + end + + def send_tik_tok_as_document(request, video_info) + caption = "🎵 *TikTok видео*\n\n" + caption += "🎬 *#{escape_md(video_info[:title])}*\n" if video_info[:title] + caption += "👤 #{escape_md(video_info[:author])}\n" if video_info[:author] + + return unless direct_media_url?(video_info[:video_url]) + + tempfile = download_tiktok_to_tempfile(video_info[:video_url], request.url) + if tempfile + bot.api.send_document( + chat_id: request.telegram_chat_id, + document: Faraday::UploadIO.new(tempfile.path, 'video/mp4', 'video.mp4'), + caption: caption, + parse_mode: 'Markdown' + ) + else + bot.api.send_document( + chat_id: request.telegram_chat_id, + document: video_info[:video_url], + caption: caption, + parse_mode: 'Markdown' + ) + end + ensure + tempfile&.close + tempfile&.unlink + end + + def direct_media_url?(url) + return false if url.blank? + url.include?('tiktokcdn') || url.match?(/\.mp4(\?|$)/) + end + + def download_tiktok_to_tempfile(video_url, referer) + return nil unless direct_media_url?(video_url) + headers = { + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer' => referer.to_s + } + response = HTTParty.get(video_url, headers: headers, timeout: 60) + return nil unless response.success? && response.body.present? + temp = Tempfile.new(['tiktok', '.mp4']) + temp.binmode + temp.write(response.body) + temp.rewind + temp + rescue StandardError => e + Rails.logger.warn("TikTok download to tempfile failed: #{e.message}") + nil + end + + def send_instagram_as_document(request, video_info) + caption = "📸 *Instagram видео*\n\n" + caption += "🎬 *#{escape_md(video_info[:title])}*\n" if video_info[:title] + caption += "👤 #{escape_md(video_info[:author])}\n" if video_info[:author] + + bot.api.send_document( + chat_id: request.telegram_chat_id, + document: video_info[:video_url], + caption: caption, + parse_mode: 'Markdown' + ) + rescue StandardError => e + Rails.logger.warn("Telegram send_document failed: #{e.message}") + end + + def send_youtube_mini_app(chat_id, video_id) + web_app_url = "https://www.youtube.com/embed/#{video_id}?autoplay=1" + + keyboard = [ + [ + Telegram::Bot::Types::InlineKeyboardButton.new( + text: "🎬 Смотреть в Telegram", + web_app: { url: web_app_url } + ) + ] + ] + + markup = Telegram::Bot::Types::InlineKeyboardMarkup.new(inline_keyboard: keyboard) + + bot.api.send_message( + chat_id: chat_id, + text: "Нажмите кнопку для просмотра в Telegram", + reply_markup: markup + ) + end + + def download_keyboard(request_id, video_info) + buttons = [] + + if video_info[:video_url] + buttons << [ + Telegram::Bot::Types::InlineKeyboardButton.new( + text: "⬇️ Скачать видео", + url: video_info[:video_url] + ) + ] + end + + buttons << [ + Telegram::Bot::Types::InlineKeyboardButton.new( + text: "ℹ️ Подробнее", + callback_data: "more_info" + ) + ] + + Telegram::Bot::Types::InlineKeyboardMarkup.new(inline_keyboard: buttons) + end + + def format_duration(seconds) + return "Неизвестно" unless seconds + + hours = seconds / 3600 + minutes = (seconds % 3600) / 60 + secs = seconds % 60 + + if hours > 0 + format("%02d:%02d:%02d", hours, minutes, secs) + else + format("%02d:%02d", minutes, secs) + end + end + + def platform_emoji(platform) + case platform.to_sym + when :youtube then '🎥' + when :instagram then '📸' + when :tiktok then '🎵' + else '🔗' + end + end +end diff --git a/app/services/video_downloader.rb b/app/services/video_downloader.rb index 518e934..b3051fe 100644 --- a/app/services/video_downloader.rb +++ b/app/services/video_downloader.rb @@ -105,8 +105,8 @@ def get_youtube_info(url) begin # Упрощенная версия без гема video_info api_url = "https://www.youtube.com/oembed?url=#{URI.encode_uri_component(url)}&format=json" - response = HTTParty.get(api_url, timeout: 10) - + response = HTTParty.get(api_url, timeout: 60) + if response.success? data = JSON.parse(response.body) @@ -115,7 +115,10 @@ def get_youtube_info(url) title: data['title'], author: data['author_name'], thumbnail: data['thumbnail_url'], - embed_url: "https://www.youtube.com/embed/#{extract_youtube_id(url)}" + video_url: url, + embed_url: "https://www.youtube.com/embed/#{extract_youtube_id(url)}", + video_id: extract_youtube_id(url), + duration: nil } else { error: "Не удалось получить информацию с YouTube" } @@ -144,8 +147,6 @@ def get_tiktok_info(url) if response.success? html = response.body - - # Парсим мета-теги doc = Nokogiri::HTML(html) meta = { @@ -154,12 +155,27 @@ def get_tiktok_info(url) image: extract_meta_content(doc, 'og:image') } + # Прямая ссылка на видео (для загрузки в Telegram) + direct_video_url = extract_tiktok_video_url(html, doc) + yt_data = fetch_tiktok_ytdlp_json(url) + direct_video_url = direct_video_url.presence || tiktok_url_from_ytdlp_json(yt_data) + + title = meta[:title].presence + author = extract_tiktok_author(meta[:title]) + yt_meta = yt_data ? build_tiktok_metadata_from_ytdlp(yt_data) : nil + if yt_meta + title = yt_meta[:title] if title.blank? || title == 'TikTok Video' + author = yt_meta[:author] if author.blank? + end + { platform: :tiktok, - title: meta[:title] || 'TikTok Video', + title: title.presence || 'TikTok Video', + author: author, description: meta[:description], thumbnail: meta[:image], - video_url: url + video_url: direct_video_url.presence || url, + duration: yt_meta&.dig(:duration) } else { error: "Не удалось загрузить страницу TikTok" } @@ -169,6 +185,75 @@ def get_tiktok_info(url) end end + # Извлечение прямой ссылки на MP4 из HTML TikTok (JSON в странице) + def extract_tiktok_video_url(html, doc) + # 1) og:video:url — иногда есть + og_video = doc.at_css("meta[property='og:video:url']") + return og_video['content'] if og_video && og_video['content']&.match?(%r{\Ahttps?://}) + + # 2) JSON в странице: url_list (часто в play_addr) + if html.include?('url_list') + # "url_list":["https:\/\/v16.tiktokcdn.com\/..."] + html.scan(/"url_list"\s*:\s*\[\s*"((?:https?:\\?\/\\?\/[^"]+))"/) do |m| + decoded = decode_tiktok_json_url(m[0]) + return decoded if decoded&.include?('tiktokcdn') + end + end + # 3) play_addr / playAddr с полем url + html.scan(/"url"\s*:\s*"((?:https?:\\?\/\\?\/[^"]*tiktokcdn[^"]*))"/) do |m| + decoded = decode_tiktok_json_url(m[0]) + return decoded if decoded + end + # 4) Любая ссылка на tiktokcdn с .mp4 + match = html.match(/"((https?:\\?\/\\?\/[^"]*tiktokcdn[^"]*\.mp4[^"]*))"/) + return decode_tiktok_json_url(match[1]) if match + + nil + end + + def decode_tiktok_json_url(escaped) + return nil unless escaped + decoded = escaped.gsub('\\/', '/').gsub('\\u0026', '&').gsub('\\u003d', '=').gsub('\\u003D', '=') + decoded = decoded.gsub(/\\u([0-9a-fA-F]{4})/) { |_| [$1.hex].pack('U').force_encoding('UTF-8') } + decoded.match?(%r{\Ahttps?://}) ? decoded : nil + end + + def extract_tiktok_author(og_title) + return nil if og_title.blank? + # "Author Name (@user) | TikTok" -> "Author Name (@user)" или оставляем как есть + og_title.sub(/\s*\|\s*TikTok\s*$/i, '').strip.presence + end + + def tiktok_url_from_ytdlp_json(data) + return nil unless data + data['url'].presence || data.dig('requested_downloads', 0, 'url') + end + + def build_tiktok_metadata_from_ytdlp(data) + return nil unless data + dur = data['duration'] + duration_sec = dur && (d = dur.to_f) && d.positive? ? d.to_i : nil + { + title: (data['title'].presence || data['fulltitle'].presence), + author: (data['uploader'].presence || data['creator'].presence || data['uploader_id'].presence&.then { "@#{_1}" }), + duration: duration_sec + } + end + + # Запасной вариант: получить URL через yt-dlp (если установлен) + def fetch_tiktok_url_via_ytdlp(url) + tiktok_url_from_ytdlp_json(fetch_tiktok_ytdlp_json(url)) + end + + def fetch_tiktok_ytdlp_json(url) + return nil unless system('which yt-dlp > /dev/null 2>&1') + json_str = `yt-dlp -j --no-download --no-warnings "#{url}" 2>/dev/null` + return nil if json_str.blank? + JSON.parse(json_str) + rescue JSON::ParserError, Errno::ENOENT + nil + end + # Вспомогательные методы def instagram_headers diff --git a/config/database.yml b/config/database.yml index 4ccafbb..25fa3bc 100644 --- a/config/database.yml +++ b/config/database.yml @@ -6,7 +6,7 @@ default: &default development: <<: *default database: video_link_bot_development - username: postgres # или ваш username + username: postgres password: <%= ENV['DATABASE_PASSWORD'] %> host: localhost port: 5432 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..62f78c3 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,44 @@ +services: + db: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_DB: video_link_bot_production + POSTGRES_USER: video_link_bot + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + + app: + build: . + restart: unless-stopped + depends_on: + - db + ports: + - "80:80" + environment: + RAILS_ENV: production + RAILS_LOG_LEVEL: info + RAILS_MASTER_KEY: ${RAILS_MASTER_KEY} + DATABASE_URL: postgres://video_link_bot:${DATABASE_PASSWORD}@db:5432/video_link_bot_production + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} + SOLID_QUEUE_IN_PUMA: "true" + # запускаем веб и solid_queue в puma (SOLID_QUEUE_IN_PUMA=true), + # а сам бот — отдельным процессом + command: ["./bin/thrust", "./bin/rails", "server"] + + bot: + build: . + restart: unless-stopped + depends_on: + - db + environment: + RAILS_ENV: production + RAILS_LOG_LEVEL: info + RAILS_MASTER_KEY: ${RAILS_MASTER_KEY} + DATABASE_URL: postgres://video_link_bot:${DATABASE_PASSWORD}@db:5432/video_link_bot_production + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} + command: ["./bin/rails", "bot:start"] + +volumes: + postgres_data: