feat(bot): proactive cookie health-check + simpler user-facing errors
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 51s
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 51s
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.
This commit is contained in:
parent
be1acaaf2b
commit
5092d882e5
3 changed files with 166 additions and 4 deletions
109
bot.py
109
bot.py
|
|
@ -1,5 +1,6 @@
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
@ -109,7 +110,7 @@ TEXTS = {
|
||||||
'downloading': "⬇️ Скачиваю видео...",
|
'downloading': "⬇️ Скачиваю видео...",
|
||||||
'sending': "📤 Отправляю видео...",
|
'sending': "📤 Отправляю видео...",
|
||||||
'caption': "Видео скачано с @{bot_username}",
|
'caption': "Видео скачано с @{bot_username}",
|
||||||
'error': "❌ Произошла ошибка при обработке видео:\n{error}",
|
'error': "❌ Something went wrong while processing your video. Please try again in a bit.",
|
||||||
'error_unknown_source': "Пардон, не умеем работать с этим источником",
|
'error_unknown_source': "Пардон, не умеем работать с этим источником",
|
||||||
'error_vk_not_video': "❌ Это ссылка VK, но не на видео. Пришлите ссылку вида vk.com/video... или vk.com/clip...",
|
'error_vk_not_video': "❌ Это ссылка VK, но не на видео. Пришлите ссылку вида vk.com/video... или vk.com/clip...",
|
||||||
'error_file_too_large': "❌ Видео слишком большое ({size_mb:.1f} МБ, max = 50)",
|
'error_file_too_large': "❌ Видео слишком большое ({size_mb:.1f} МБ, max = 50)",
|
||||||
|
|
@ -167,7 +168,7 @@ TEXTS = {
|
||||||
'downloading': "⬇️ Downloading video...",
|
'downloading': "⬇️ Downloading video...",
|
||||||
'sending': "📤 Sending video...",
|
'sending': "📤 Sending video...",
|
||||||
'caption': "Video downloaded via @{bot_username}",
|
'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_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_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)",
|
'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}")
|
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)
|
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:
|
try:
|
||||||
await item.status_message.edit_text(error_msg)
|
await item.status_message.edit_text(error_msg)
|
||||||
except:
|
except:
|
||||||
|
|
@ -1447,7 +1536,19 @@ def main():
|
||||||
|
|
||||||
asyncio.create_task(periodic_cleanup())
|
asyncio.create_task(periodic_cleanup())
|
||||||
logger.info("Фоновая задача периодической очистки файлов запущена")
|
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
|
application.post_init = post_init
|
||||||
|
|
||||||
# Запускаем бота
|
# Запускаем бота
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,36 @@ def health():
|
||||||
return jsonify({'status': 'ok', 'service': 'instagram-downloader'}), 200
|
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'])
|
@app.route('/download/stream', methods=['POST'])
|
||||||
def download_stream():
|
def download_stream():
|
||||||
"""Скачивает видео с Instagram и возвращает бинарные данные"""
|
"""Скачивает видео с Instagram и возвращает бинарные данные"""
|
||||||
|
|
|
||||||
|
|
@ -486,6 +486,37 @@ def formats():
|
||||||
return jsonify({'error': str(e)}), 500
|
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'])
|
@app.route('/download/stream', methods=['POST'])
|
||||||
def download_stream():
|
def download_stream():
|
||||||
request_id = str(uuid.uuid4())[:8]
|
request_id = str(uuid.uuid4())[:8]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue