# /opt/trueconf_bot/photo_bot/handlers.py import os import asyncio import logging import smtplib import time from datetime import datetime from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from PIL import Image from trueconf import Router, Message from trueconf.types import FSInputFile import config.config as config from utils import states from utils.menu import MENU_TEXT from photo_bot.photo_processor import prepare_ad_photo, get_crop_coordinates from photo_bot.ai_validator import validate_photo_with_ai from utils.texts import ( EMOJI_DIGITS, PHOTO_MAIN_MENU_TEXT, system_error_text, photo_error_handling, PHOTO_PROCESSING_FACE_TEXT, PHOTO_AI_PROCESSING_TEXT, photo_success_published, photo_success_pending, photo_error_rejected, photo_error_already_pending, photo_cancel_text, photo_preview_ready, photo_welcome_with_status, photo_test_welcome, PHOTO_TEST_WELCOME_TEXT, PHOTO_TEST_PREVIEW_TEXT, PHOTO_TEST_AI_CHECKING_TEXT, photo_test_result, photo_test_ai_error, ) # 🔌 Импортируем централизованную функцию сбора статистики из main from utils.stats_logger import log_menu_stats logger = logging.getLogger(__name__) router = Router() # Словарь для временного хранения путей к фото пользователей # Формат: {user_id: {'path': '/tmp/orig_user.jpg', 'coords': (x1,y1,x2,y2)}} pending_photos = {} # Пользователи, находящиеся в под-режиме «тест проверка фото» test_submode_users = set() # Пользователи, у которых фото на модерации (вход заблокирован, # модуль остаётся открыт: в тест-режиме можно отправить фото на тест) moderation_pending_users = set() def in_test_submode(user_id: str) -> bool: """Проверяет, находится ли пользователь в под-режиме тест-проверки фото""" if user_id not in test_submode_users: return False # Если сессия истекла (состояние сброшено) — под-режим тоже недействителен return states.get_state(user_id) == "PHOTO_MODE" # ========================================================= # СИСТЕМА ОБРАБОТКИ ОШИБОК И ЛОГИРОВАНИЯ # ========================================================= EMAIL_COOLDOWN_SEC = 10.0 last_email_time = 0.0 def log_and_notify_photo_error(user_id: str, action: str, error_details: str): """Записывает системную ошибку в лог-файл и отправляет email-алерт администраторам""" global last_email_time log_dir = os.path.dirname(os.path.abspath(__file__)) os.makedirs(log_dir, exist_ok=True) log_file = os.path.join(log_dir, "photo_errors.log") current_time = time.time() timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") log_entry = f"[{timestamp}] User: {user_id} | Action: {action} | Error: {error_details}\n" with open(log_file, "a", encoding="utf-8") as f: f.write(log_entry) if current_time - last_email_time < EMAIL_COOLDOWN_SEC: with open(log_file, "a", encoding="utf-8") as f: f.write(f"[{timestamp}] ALERT: Email suppressed due to rate limiting.\n") return last_email_time = current_time try: msg = MIMEMultipart() msg['From'] = str(getattr(config, 'DEFAULT_EMAIL_FROM', 'bot@noreply.com')) to_emails = getattr(config, 'ALERTS_SUPPORT_EMAILS', 'admin@example.com') msg['To'] = ", ".join(to_emails) if isinstance(to_emails, list) else str(to_emails) msg['Subject'] = f"TrueConf Bot Error: Фото Профиль - {action}" body = ( f"Обнаружена системная ошибка в модуле загрузки фото профиля.\n\n" f"Время: {timestamp}\n" f"Пользователь (ID): {user_id}\n" f"Действие: {action}\n\n" f"Детали ошибки:\n{error_details}\n\n" f"(Уведомления об ошибках ограничены: не чаще 1 раза в {EMAIL_COOLDOWN_SEC} сек.)" ) msg.attach(MIMEText(body, 'plain', 'utf-8')) smtp_server = getattr(config, 'SMTP_SERVER', 'localhost') smtp_port = int(getattr(config, 'SMTP_PORT', 25)) with smtplib.SMTP(smtp_server, smtp_port) as server: smtp_user = getattr(config, 'SMTP_USER', None) smtp_pass = getattr(config, 'SMTP_PASSWORD', None) if smtp_user and smtp_pass: server.login(smtp_user, smtp_pass) server.send_message(msg) except Exception as e: with open(log_file, "a", encoding="utf-8") as f: f.write(f"[{timestamp}] СБОЙ ОТПРАВКИ EMAIL: {e}\n") async def handle_photo_error(msg: Message, user_id: str, action: str, error_details: str): """Вызывает системное логирование и выдает заглушку пользователю""" logger.error(f"🔥 Ошибка фото-бота для {user_id}, действие: {action}: {error_details}") await asyncio.to_thread(log_and_notify_photo_error, user_id, action, error_details) await msg.answer(photo_error_handling(), parse_mode="html") async def check_moderation_status(user_id: str) -> dict: """Проверяет текущий статус модерации пользователя через API""" from photo_bot.photo_processor import API_URL, API_KEY import requests # Извлекаем логин из email (user_id@tcs.sibcem.ru -> user_id) login = user_id.split('@')[0] status_url = API_URL.replace("/upload/", "/status/") try: response = requests.get( status_url, params={"user_id": login}, headers={"X-API-Key": API_KEY}, timeout=15, verify=False ) response.raise_for_status() return response.json() except Exception as e: logger.error(f"Ошибка проверки статуса модерации: {e}") return {"status": "error", "reason": str(e)} # ========================================================= # ВХОД В РАЗДЕЛ ФОТО: проверка статуса модерации + показ меню # ========================================================= async def send_dynamic_photo_main_menu(msg: Message, user_id: str): """Вход в раздел фото (паттерн ЛК): модуль сам запрашивает данные и показывает их. - pending — сообщение «фото на модерации»; модуль остаётся открыт (цифры → ошибка неверного выбора, 0 — выход). Тест-режим: фото можно отправить на проверку в ИИ; - rejected — ОДНО сообщение: меню + причина отказа (кнопки на месте); - иначе/ошибка API — штатное меню входа. Статус проверяется и показывается всегда, включая тестовый режим. """ # Статус модерации запрашиваем всегда (и в тестовом режиме тоже) status = await check_moderation_status(user_id) if status.get("status") == "ok" and status.get("moderation_status") == "pending": # Фото на модерации — штатная загрузка заблокирована, # но модуль остаётся открыт (PHOTO_MODE), чтобы цифры не ушли в главное меню log_menu_stats(user_id, "Photo Bot", "Вход — фото на модерации") states.set_state(user_id, "PHOTO_MODE") moderation_pending_users.add(user_id) # Единый вход: «фото на модерации» (+ кнопка 1 — тест, если тест-режим включён). # Описание тест-режима показываем ТОЛЬКО после нажатия кнопки 1 await msg.answer( photo_welcome_with_status(status, show_test=config.PHOTO_TEST_MODE), parse_mode="html" ) return if status.get("status") == "ok" and status.get("moderation_status") == "rejected": log_menu_stats(user_id, "Photo Bot", "Вход — фото отклонено, показана причина") elif status.get("status") != "ok": logger.warning( f"Не удалось проверить статус модерации для {user_id}: {status.get('reason')}" ) status = None states.set_state(user_id, "PHOTO_MODE") log_menu_stats(user_id, "Photo Bot (TEST)" if config.PHOTO_TEST_MODE else "Photo Bot", "Вход") # Одно универсальное сообщение: меню (+ тест-кнопка) + (если нужно) статус модерации entry_text = photo_welcome_with_status(status, show_test=config.PHOTO_TEST_MODE) await msg.answer(entry_text, parse_mode="html") # ========================================================= # ОСНОВНОЙ ОБРАБОТЧИК # ========================================================= @router.message() async def photo_module_handler(msg: Message): user_id = msg.from_user.id if states.get_state(user_id) != "PHOTO_MODE": return # Помечаем сообщение как обработанное msg.handled = True user_login = user_id.split('@')[0] if '@' in user_id else user_id msg_text = getattr(msg.content, "text", "").strip() if hasattr(msg.content, "text") else "" is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT" cmd = msg_text.lower() # 🔄 НОРМАЛИЗАЦИЯ КНОПОК ВК-ЭМОДЗИ for raw_num, emoji_num in EMOJI_DIGITS.items(): if cmd == emoji_num: cmd = raw_num break # --- ТЕСТ-ПОД-РЕЖИМ: 9 = назад в меню фото (не выход из модуля) --- if in_test_submode(user_id) and cmd == "9" and not is_attachment: test_submode_users.discard(user_id) log_menu_stats(user_id, "Photo Bot (TEST)", "Назад из тест-под-режима") # Висящее тест-фото снимаем (тест-режим: оно никуда не публиковалось) if user_id in pending_photos: photo_data = pending_photos[user_id] if os.path.exists(photo_data['path']): os.remove(photo_data['path']) if 'preview_path' in photo_data and os.path.exists(photo_data['preview_path']): os.remove(photo_data['preview_path']) del pending_photos[user_id] status = await check_moderation_status(user_id) await msg.answer( photo_welcome_with_status(status, show_test=config.PHOTO_TEST_MODE), parse_mode="html" ) return # --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню с очисткой кэша) --- if cmd in ["0", "9", "/start", "меню"]: log_menu_stats(user_id, "Photo Bot", "Выход в главное меню") states.clear_state(user_id) test_submode_users.discard(user_id) moderation_pending_users.discard(user_id) if user_id in pending_photos: photo_data = pending_photos[user_id] if os.path.exists(photo_data['path']): os.remove(photo_data['path']) if 'preview_path' in photo_data and os.path.exists(photo_data['preview_path']): os.remove(photo_data['preview_path']) del pending_photos[user_id] await msg.answer(MENU_TEXT, parse_mode="html") return # --- СОСТОЯНИЕ «ФОТО НА МОДЕРАЦИИ»: активного фото нет --- if user_id in moderation_pending_users and user_id not in pending_photos: if not is_attachment: status = await check_moderation_status(user_id) still_pending = ( status.get("status") == "ok" and status.get("moderation_status") == "pending" ) if still_pending: if config.PHOTO_TEST_MODE and cmd == "1": # Кнопка 1 — вход в тест-под-режим (можно отправить фото в ИИ) test_submode_users.add(user_id) log_menu_stats(user_id, "Photo Bot (TEST)", "Тест-под-режим (фото на модерации)") await msg.answer(PHOTO_TEST_WELCOME_TEXT, parse_mode="html") else: # Неверная команда → глобальное сообщение await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html") return # Статус изменился (одобрено/отклонено) — разблокируем was_in_test = in_test_submode(user_id) moderation_pending_users.discard(user_id) if config.PHOTO_TEST_MODE and was_in_test: await msg.answer(PHOTO_TEST_WELCOME_TEXT, parse_mode="html") else: await msg.answer( photo_welcome_with_status(status), parse_mode="html" ) return # is_attachment — пропускаем: фото обрабатывается штатно ниже # (тест-под-режим: превью + отправка в ИИ) # --- ПРОВЕРКА СТАТУСА: если нет фото в pending_photos, проверяем статус модерации --- # (в тестовом режиме статусы тоже показываем — поведение как в обычном режиме) if user_id not in pending_photos and not is_attachment: status = await check_moderation_status(user_id) if status.get("status") == "ok" and status.get("moderation_status") == "pending": await msg.answer( photo_welcome_with_status(status, show_test=config.PHOTO_TEST_MODE), parse_mode="html" ) return elif status.get("status") != "ok": # Ошибка проверки — не блокируем, просто логируем logger.warning(f"Не удалось проверить статус модерации для {user_id}: {status.get('reason')}") # --- ВХОД В ТЕСТ-ПОД-РЕЖИМ: кнопка 1 в меню фото-режима (тест включен) --- if (not is_attachment and user_id not in pending_photos and not in_test_submode(user_id) and config.PHOTO_TEST_MODE and cmd == "1"): test_submode_users.add(user_id) log_menu_stats(user_id, "Photo Bot (TEST)", "Вход в тест-под-режим") await msg.answer(PHOTO_TEST_WELCOME_TEXT, parse_mode="html") return # --- ТЕСТ-ПОД-РЕЖИМ: кнопка 1 отправляет фото НАПРЯМУЮ в ИИ, фото никуда не публикуется --- if not is_attachment and user_id in pending_photos and in_test_submode(user_id): if cmd == "1": photo_data = pending_photos[user_id] original_path = photo_data['path'] await msg.answer(PHOTO_TEST_AI_CHECKING_TEXT, parse_mode="html") try: with open(original_path, 'rb') as f: raw_photo = f.read() ai_res = await asyncio.to_thread(validate_photo_with_ai, raw_photo) except Exception as e: logger.error(f"🔥 [TEST MODE] Ошибка чтения фото: {e}") ai_res = {'ok': False, 'verdict': '', 'reason': '', 'error': str(e)} if not ai_res['ok']: # Фото остаётся в pending — можно повторить проверку (кнопка 1) log_menu_stats(user_id, "Photo Bot (TEST)", "Ошибка обращения к ИИ") await msg.answer( photo_test_ai_error(ai_res.get('error', ''), ai_res.get('error_time')), parse_mode="html" ) return verdict = ai_res['verdict'] reason = ai_res['reason'] or 'Причина не указана.' mimi = ai_res.get('mimi', '') grafika = ai_res.get('grafika', '') log_menu_stats(user_id, "Photo Bot (TEST)", f"ИИ-вердикт: {verdict}") # Фото остаётся в pending — можно повторить проверку другим фото (кнопка 2) await msg.answer( photo_test_result(verdict, reason, mimi, grafika, ai_res.get('metrics')), parse_mode="html" ) return elif cmd == "2": log_menu_stats(user_id, "Photo Bot (TEST)", "Загрузка другого фото") photo_data = pending_photos[user_id] original_path = photo_data['path'] if os.path.exists(original_path): os.remove(original_path) if 'preview_path' in photo_data and os.path.exists(photo_data['preview_path']): os.remove(photo_data['preview_path']) del pending_photos[user_id] await msg.answer(PHOTO_TEST_WELCOME_TEXT, parse_mode="html") return # --- Неверная команда при висящем превью → глобальное сообщение --- else: await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html") return # --- ПОДТВЕРЖДЕНИЕ ОТПРАВКИ (Кнопка 1) --- if not is_attachment and user_id in pending_photos: if cmd == "1": photo_data = pending_photos[user_id] original_path = photo_data['path'] await msg.answer(PHOTO_AI_PROCESSING_TEXT, parse_mode="html") try: # Отправляем фото через API result = await asyncio.to_thread( prepare_ad_photo, original_path, user_login ) if result["success"]: verdict = result.get("verdict") if verdict == "published": log_menu_stats(user_id, "Photo Bot", "Фото опубликовано") await msg.answer(photo_success_published(), parse_mode="html") elif verdict == "pending_moderation": log_menu_stats(user_id, "Photo Bot", "Фото на модерации") await msg.answer(photo_success_pending(), parse_mode="html") else: msg_text = result.get("message", "Фото обработано") await msg.answer(f"✅ {msg_text}", parse_mode="html") else: error_msg = result.get("error", "Неизвестная ошибка") # Если фото уже на модерации — показываем конкретное сообщение if "уже есть фото на модерации" in error_msg: log_menu_stats(user_id, "Photo Bot", "Попытка повторной загрузки на модерации") await msg.answer(photo_error_already_pending(), parse_mode="html") else: await msg.answer(photo_error_handling(), parse_mode="html") except Exception as e: await handle_photo_error(msg, user_id, "Отправка фото через API", str(e)) # Убираем за собой if os.path.exists(original_path): os.remove(original_path) if 'preview_path' in photo_data and os.path.exists(photo_data['preview_path']): os.remove(photo_data['preview_path']) states.clear_state(user_id) if user_id in pending_photos: del pending_photos[user_id] return # --- ОТМЕНА ФОТО (Кнопка 2) --- elif cmd == "2": log_menu_stats(user_id, "Photo Bot", "Отмена загруженного фото") photo_data = pending_photos[user_id] original_path = photo_data['path'] if os.path.exists(original_path): os.remove(original_path) if 'preview_path' in photo_data and os.path.exists(photo_data['preview_path']): os.remove(photo_data['preview_path']) del pending_photos[user_id] states.clear_state(user_id) await msg.answer(photo_cancel_text(), parse_mode="html") return # --- ИСПРАВЛЕНО: Защита от мусорного ввода при висящем превью --- else: await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html") return # --- НЕВЕРНАЯ КОМАНДА (висящего фото нет) → глобальное сообщение --- if not is_attachment: await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html") return # --- ПРИЕМ НОВОГО ФОТО --- if is_attachment: file_id = getattr(msg.content, "file_id", None) raw_bytes = None await msg.answer(PHOTO_PROCESSING_FACE_TEXT, parse_mode="html") try: downloaded_file = await states.tc_bot.download_file_by_id(file_id) logger.info(f"Скачан файл для {user_login}: type={type(downloaded_file)}, len={len(downloaded_file) if isinstance(downloaded_file, (bytes, str)) else 'N/A'}") # Скачиваем в байты напрямую if isinstance(downloaded_file, bytes): raw_bytes = downloaded_file elif isinstance(downloaded_file, str) and os.path.exists(downloaded_file): with open(downloaded_file, 'rb') as f: raw_bytes = f.read() else: # Пробуем как путь raw_path = downloaded_file if isinstance(downloaded_file, str) else None if raw_path and os.path.exists(raw_path): with open(raw_path, 'rb') as f: raw_bytes = f.read() else: raise Exception(f"Не удалось получить байты файла: {type(downloaded_file)}") logger.info(f"Прочитано {len(raw_bytes)} байт для {user_login}") # Ищем лицо и получаем координаты coords = await asyncio.to_thread(get_crop_coordinates, raw_bytes) logger.info(f"get_crop_coordinates вернул: {coords} для {user_login}") if not coords: await msg.answer( "❌ Лицо не найдено. Пожалуйста, сделайте фото анфас при хорошем освещении.\n\n" f"{EMOJI_DIGITS['0']} — В главное меню", parse_mode="html" ) return # Генерируем превью try: from PIL import Image import io img = Image.open(io.BytesIO(raw_bytes)) if img.mode != 'RGB': img = img.convert('RGB') x1, y1, x2, y2 = coords cropped = img.crop((x1, y1, x2, y2)) # Ресайз до квадрата side = min(cropped.size[0], cropped.size[1]) left = (cropped.size[0] - side) // 2 top = (cropped.size[1] - side) // 2 cropped = cropped.crop((left, top, left + side, top + side)) preview = cropped.resize((400, 400), Image.Resampling.LANCZOS) preview_path = f"/tmp/prev_{user_login}.jpg" preview.save(preview_path, format="JPEG", quality=85) preview_doc = FSInputFile(preview_path, filename="preview.jpg") await msg.answer_photo(preview_doc, preview_doc) except Exception as lib_err: await asyncio.to_thread(log_and_notify_photo_error, user_id, "Генерация превью", str(lib_err)) raise Exception("Ошибка генерации превью") # Сохраняем оригинал и координаты original_path = f"/tmp/orig_{user_login}.jpg" with open(original_path, 'wb') as f: f.write(raw_bytes) pending_photos[user_id] = { 'path': original_path, 'coords': coords, 'preview_path': preview_path } log_menu_stats(user_id, "Photo Bot", "Загрузка и обработка нового фото") await asyncio.sleep(0.5) if in_test_submode(user_id): await msg.answer( PHOTO_TEST_PREVIEW_TEXT, parse_mode="html" ) else: await msg.answer( photo_preview_ready(), parse_mode="html" ) except Exception as e: logger.error(f"🔥 Полная ошибка: {e}", exc_info=True) await handle_photo_error(msg, user_id, "Скачивание и обработка файла", str(e)) return