From 5092d882e5038b44ba7b0b1814aee06c869046d7 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Sat, 25 Jul 2026 22:30:49 +0000 Subject: [PATCH] feat(bot): proactive cookie health-check + simpler user-facing errors Add /cookies/check to youtube-downloader and instagram-downloader, polled every 30 min from bot.py; alerts the admin bot on healthy->broken transitions (with a 24h re-reminder cap while still broken) and on recovery. Also stop leaking raw exception text to end users - they now see a plain, friendly English message while the admin bot still gets full technical detail via notify_admin_error. --- bot.py | 109 ++++++++++++++++++++++++++++++++++-- instagram-downloader/app.py | 30 ++++++++++ youtube-downloader/app.py | 31 ++++++++++ 3 files changed, 166 insertions(+), 4 deletions(-) diff --git a/bot.py b/bot.py index f322903..f15f677 100644 --- a/bot.py +++ b/bot.py @@ -1,5 +1,6 @@ import os import re +import json import logging import asyncio import sqlite3 @@ -109,7 +110,7 @@ TEXTS = { 'downloading': "⬇️ Скачиваю видео...", 'sending': "📤 Отправляю видео...", 'caption': "Видео скачано с @{bot_username}", - 'error': "❌ Произошла ошибка при обработке видео:\n{error}", + 'error': "❌ Something went wrong while processing your video. Please try again in a bit.", 'error_unknown_source': "Пардон, не умеем работать с этим источником", 'error_vk_not_video': "❌ Это ссылка VK, но не на видео. Пришлите ссылку вида vk.com/video... или vk.com/clip...", 'error_file_too_large': "❌ Видео слишком большое ({size_mb:.1f} МБ, max = 50)", @@ -167,7 +168,7 @@ TEXTS = { 'downloading': "⬇️ Downloading video...", 'sending': "📤 Sending video...", 'caption': "Video downloaded via @{bot_username}", - 'error': "❌ Error processing video:\n{error}", + 'error': "❌ Something went wrong while processing your video. Please try again in a bit.", 'error_unknown_source': "Sorry, this source is not supported", 'error_vk_not_video': "❌ This is a VK link, but not a video. Send a vk.com/video... or vk.com/clip... link.", 'error_file_too_large': "❌ Video is too large ({size_mb:.1f} MB, max = 50)", @@ -597,6 +598,94 @@ async def notify_admin_error(url: str, error_text: str, from_user=None): logger.error(f"Ошибка при отправке уведомления об ошибке админ боту: {e}") +COOKIE_STATUS_FILE = DATA_DIR / 'cookie_health_state.json' +COOKIE_CHECK_INTERVAL = 30 * 60 # 30 минут — дешёвая read-only проверка +COOKIE_REMINDER_INTERVAL = 24 * 3600 # не чаще раза в сутки повторное напоминание, пока всё ещё сломано + + +def _load_cookie_state() -> dict: + try: + return json.loads(COOKIE_STATUS_FILE.read_text()) + except Exception: + return {"youtube": "healthy", "instagram": "healthy", "last_reminder_ts": {}} + + +def _save_cookie_state(state: dict): + try: + COOKIE_STATUS_FILE.write_text(json.dumps(state)) + except Exception as e: + logger.error(f"Не удалось сохранить состояние cookie-health: {e}") + + +async def notify_admin_cookie_alert(service: str, healthy: bool, detail: str): + """Проактивное уведомление о состоянии cookies (health-check), не связано с ошибкой конкретного пользователя""" + if not ADMIN_BOT_TOKEN: + return + admin_chat_id = get_admin_chat_id() + if not admin_chat_id: + return + + try: + request = HTTPXRequest( + read_timeout=30, + write_timeout=30, + connect_timeout=15, + pool_timeout=15 + ) + admin_bot = Bot(token=ADMIN_BOT_TOKEN, request=request) + + if healthy: + text = f"✅ Cookies восстановлены\n\n🍪 Сервис: {service}\nCookies снова рабочие." + else: + text = f"🍪⚠️ ПРОАКТИВНАЯ ПРОВЕРКА COOKIES\n\n🍪 Сервис: {service}\n❗ {detail}" + if len(text) > 4000: + text = text[:4000] + "…" + + await admin_bot.send_message(chat_id=admin_chat_id, text=text) + logger.info(f"Уведомление о состоянии cookies ({service}) отправлено админ боту") + + except Exception as e: + logger.error(f"Ошибка при отправке уведомления о cookies админ боту: {e}") + + +async def check_cookies_health(): + """Периодическая проверка здоровья cookies YouTube и Instagram""" + state = _load_cookie_state() + checks = [ + ('youtube', f"{YOUTUBE_DOWNLOADER_URL}/cookies/check"), + ('instagram', f"{INSTAGRAM_DOWNLOADER_URL}/cookies/check"), + ] + for service, endpoint in checks: + try: + async with httpx.AsyncClient(timeout=FORMATS_TIMEOUT) as client: + response = await client.post(endpoint) + data = response.json() + except Exception as e: + logger.warning(f"Проверка cookies {service} не удалась (сервис недоступен?): {e}") + continue # не меняем состояние при сетевой ошибке — только при чистом ответе cookies_valid:false + + if data.get('cookies_valid') is None: + continue # куки для этого сервиса не настроены — нечего проверять + + currently_healthy = bool(data.get('cookies_valid')) + was_healthy = state.get(service, 'healthy') == 'healthy' + + if not currently_healthy and was_healthy: + state[service] = 'broken' + state.setdefault('last_reminder_ts', {})[service] = time.time() + await notify_admin_cookie_alert(service, healthy=False, detail=data.get('detail', '')) + elif not currently_healthy and not was_healthy: + last = state.get('last_reminder_ts', {}).get(service, 0) + if time.time() - last >= COOKIE_REMINDER_INTERVAL: + state.setdefault('last_reminder_ts', {})[service] = time.time() + await notify_admin_cookie_alert(service, healthy=False, detail=data.get('detail', '')) + elif currently_healthy and not was_healthy: + state[service] = 'healthy' + await notify_admin_cookie_alert(service, healthy=True, detail='') + + _save_cookie_state(state) + + # ============================================================================ # СИСТЕМА ОЧЕРЕДЕЙ # ============================================================================ @@ -731,7 +820,7 @@ async def process_queue_item(item: QueueItem): await notify_admin_error(item.url, str(e), item.original_message.from_user) - error_msg = get_text(item.locale, 'error', error=str(e)) + error_msg = get_text(item.locale, 'error') try: await item.status_message.edit_text(error_msg) except: @@ -1447,7 +1536,19 @@ def main(): asyncio.create_task(periodic_cleanup()) logger.info("Фоновая задача периодической очистки файлов запущена") - + + # Проактивная проверка здоровья cookies (YouTube, Instagram) + async def periodic_cookie_check(): + while True: + await asyncio.sleep(COOKIE_CHECK_INTERVAL) + try: + await check_cookies_health() + except Exception as e: + logger.error(f"Ошибка при проверке здоровья cookies: {e}") + + asyncio.create_task(periodic_cookie_check()) + logger.info("Фоновая задача проверки здоровья cookies запущена") + application.post_init = post_init # Запускаем бота diff --git a/instagram-downloader/app.py b/instagram-downloader/app.py index a7d47ff..ded7b8d 100644 --- a/instagram-downloader/app.py +++ b/instagram-downloader/app.py @@ -200,6 +200,36 @@ def health(): return jsonify({'status': 'ok', 'service': 'instagram-downloader'}), 200 +INSTAGRAM_COOKIE_TEST_URL = os.getenv( + 'INSTAGRAM_COOKIE_TEST_URL', + 'https://www.instagram.com/reel/Cvuh19eNWv_/' # стабильный публичный ролик (NASA) +) + + +@app.route('/cookies/check', methods=['POST']) +def cookies_check(): + """Проверяет, рабочие ли сейчас Instagram cookies (для проактивного мониторинга).""" + cookies_file = Path(os.getenv('INSTAGRAM_COOKIES_FILE', 'instagram_cookies.txt')) + if not cookies_file.exists(): + return jsonify({'cookies_present': False, 'cookies_valid': None, + 'detail': 'cookies file missing'}), 200 + + ydl_opts = { + 'quiet': True, + 'no_warnings': True, + 'socket_timeout': 20, + 'cookiefile': str(cookies_file.absolute()), + 'http_headers': {'Referer': 'https://www.instagram.com/'}, + } + try: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.extract_info(INSTAGRAM_COOKIE_TEST_URL, download=False) + return jsonify({'cookies_present': True, 'cookies_valid': True, 'detail': ''}), 200 + except Exception as e: + return jsonify({'cookies_present': True, 'cookies_valid': False, + 'detail': str(e)[-500:]}), 200 + + @app.route('/download/stream', methods=['POST']) def download_stream(): """Скачивает видео с Instagram и возвращает бинарные данные""" diff --git a/youtube-downloader/app.py b/youtube-downloader/app.py index f0f08e4..9f9a32d 100644 --- a/youtube-downloader/app.py +++ b/youtube-downloader/app.py @@ -486,6 +486,37 @@ def formats(): return jsonify({'error': str(e)}), 500 +YOUTUBE_COOKIE_TEST_URL = os.getenv( + 'YOUTUBE_COOKIE_TEST_URL', + 'https://www.youtube.com/watch?v=jNQXAC9IVRw' # "Me at the zoo" — первое видео на YouTube, публичное, без возрастных ограничений +) +YOUTUBE_COOKIE_INVALID_MARKERS = ('no longer valid', 'have likely been rotated') + + +@app.route('/cookies/check', methods=['POST']) +def cookies_check(): + """Проверяет, рабочие ли сейчас YouTube cookies (для проактивного мониторинга).""" + cookies_file = Path(os.getenv('YOUTUBE_COOKIES_FILE', '/app/youtube_cookies.txt')) + if not (cookies_file.exists() and cookies_file.stat().st_size > 0): + return jsonify({'cookies_present': False, 'cookies_valid': None, + 'detail': 'cookies file missing or empty'}), 200 + + # Без --quiet/--no-warnings: сигнал ротации кук — это подавляемый WARNING, а не ошибка с ненулевым returncode + cmd = _build_ytdlp_base_cmd() + ['--dump-json', YOUTUBE_COOKIE_TEST_URL] + try: + result = _run_ytdlp(cmd, timeout=INFO_TIMEOUT) + except Exception as e: + return jsonify({'cookies_present': True, 'cookies_valid': False, + 'detail': f'yt-dlp invocation failed: {e}'}), 200 + + stderr = result.stderr + if any(marker in stderr for marker in YOUTUBE_COOKIE_INVALID_MARKERS) or result.returncode != 0: + return jsonify({'cookies_present': True, 'cookies_valid': False, + 'detail': stderr.strip()[-500:]}), 200 + + return jsonify({'cookies_present': True, 'cookies_valid': True, 'detail': ''}), 200 + + @app.route('/download/stream', methods=['POST']) def download_stream(): request_id = str(uuid.uuid4())[:8]