# /opt/trueconf_bot/photo_bot/handlers.py import os import asyncio import logging import httpx 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 utils.texts import * # 👈 Импортируем все тексты и константы проекта # 🔌 Бизнес-логика обработки и ИИ-валидации from photo_bot.photo_processor import prepare_ad_photo from photo_bot.ai_validator import validate_photo_ai # 🔌 Импортируем централизованную функцию сбора статистики из main from utils.stats_logger import log_menu_stats logger = logging.getLogger(__name__) router = Router() # Словарь для временного хранения путей к фото пользователей pending_photos = {} # ========================================================= # СИСТЕМА ОБРАБОТКИ ОШИБОК И ЛОГИРОВАНИЯ # ========================================================= 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): """Вызывает системное логирование и выдает заглушку пользователю""" await asyncio.to_thread(log_and_notify_photo_error, user_id, action, error_details) await msg.answer(photo_error_handling(), 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 # --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню с очисткой кэша) --- if cmd in ["0", "9", "/start", "меню"]: log_menu_stats(user_id, "Photo Bot", "Выход в главное меню") states.clear_state(user_id) if user_id in pending_photos: if os.path.exists(pending_photos[user_id]): os.remove(pending_photos[user_id]) del pending_photos[user_id] await msg.answer(MENU_TEXT, parse_mode="html") return # --- ПОДТВЕРЖДЕНИЕ ОТПРАВКИ (Кнопка 1 или 2) --- if not is_attachment and user_id in pending_photos: if cmd == "1": photo_path = pending_photos[user_id] final_filename = f"{user_login}.bmp" await msg.answer(PHOTO_UPLOADING_TEXT, parse_mode="html") try: async with httpx.AsyncClient(timeout=10.0) as client: with open(photo_path, "rb") as f: files = {'file': (final_filename, f, 'image/bmp')} response = await client.post(config.TRASH_API_URL, files=files) if response.status_code == 200: log_menu_stats(user_id, "Photo Bot", "Отправка фото на утверждение") await msg.answer(photo_success_sent(), parse_mode="html") states.clear_state(user_id) if os.path.exists(photo_path): os.remove(photo_path) del pending_photos[user_id] await asyncio.sleep(0.5) await msg.answer(MENU_TEXT, parse_mode="html") else: error_msg = f"API вернул статус-код {response.status_code}" await handle_photo_error(msg, user_id, "Отправка фото по API", error_msg) except Exception as e: await handle_photo_error(msg, user_id, "Подключение к API загрузки", str(e)) return # --- ОТМЕНА ФОТО (Кнопка 2) --- elif cmd == "2": log_menu_stats(user_id, "Photo Bot", "Отмена загруженного фото") photo_path = pending_photos[user_id] if os.path.exists(photo_path): os.remove(photo_path) del pending_photos[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 is_attachment: file_id = getattr(msg.content, "file_id", None) ready_path = f"/tmp/{user_login}.bmp" raw_path = f"/tmp/raw_{user_login}.webp" await msg.answer(PHOTO_PROCESSING_TEXT, parse_mode="html") try: # 1. Скачиваем байты файла из TrueConf file_bytes = await states.tc_bot.download_file_by_id(file_id) # 2. Сохраняем байты во временный файл на диск def save_raw_bytes(b_data, target_path): with open(target_path, "wb") as f: f.write(b_data) await asyncio.to_thread(save_raw_bytes, file_bytes, raw_path) # 3. Техническая конвертация в BMP без геометрических проверок def convert_to_bmp_only(src_path, dst_path): with Image.open(src_path) as img: img.convert("RGB").save(dst_path, format="BMP") await asyncio.to_thread(convert_to_bmp_only, raw_path, ready_path) # 4. ИИ-ПРОВЕРКА ЧЕРЕЗ GEMMA 4 VISION (srvkem-ii-03) ai_res = await validate_photo_ai(raw_path) if ai_res["success"] and ai_res["approved"]: # --- УСПЕШНАЯ ПРОВЕРКА ИИ --- log_menu_stats(user_id, "Photo Bot", "Загрузка фото: Одобрено ИИ") pending_photos[user_id] = ready_path preview_path = f"/tmp/prev_{user_login}.jpg" try: Image.open(ready_path).save(preview_path, format="JPEG") p_main = FSInputFile(preview_path, filename="preview.jpg") p_thumb = FSInputFile(preview_path, filename="preview_thumb.jpg") await msg.answer_photo(p_main, p_thumb) except Exception as lib_err: await asyncio.to_thread(log_and_notify_photo_error, user_id, "Генерация превью", str(lib_err)) finally: if os.path.exists(preview_path): os.remove(preview_path) await asyncio.sleep(0.5) await msg.answer(photo_preview_ready(), parse_mode="html") else: # --- ИИ ОТКЛОНИЛ ФОТО ИЛИ ПРОИЗОШЛА ОШИБКА --- log_menu_stats(user_id, "Photo Bot", "Загрузка фото: Отклонено ИИ") reason_text = ai_res["text"] if ai_res["success"] else ai_res.get("error", "Не удалось проверить фото через ИИ.") await msg.answer( f"❌ Фотография не прошла проверку\n\n{reason_text}", parse_mode="html" ) except Exception as e: await handle_photo_error(msg, user_id, "Скачивание и обработка файла", str(e)) finally: if raw_path and os.path.exists(raw_path): try: os.remove(raw_path) except Exception as cleanup_err: await asyncio.to_thread(log_and_notify_photo_error, user_id, "Очистка временных файлов", str(cleanup_err)) return