Files
trueconf_bot/photo_bot/handlers.py
T

232 lines
9.5 KiB
Python

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 photo_bot.photo_ai_checker import verify_photo_with_ai
from utils.texts import (
EMOJI_DIGITS,
PHOTO_MAIN_MENU_TEXT,
PHOTO_UNKNOWN_CMD_TEXT,
photo_error_handling,
PHOTO_UPLOADING_TEXT,
photo_success_sent,
photo_cancel_text,
PHOTO_PROCESSING_TEXT,
photo_preview_ready,
photo_error_processing,
)
# 🔌 Импортируем централизованную функцию сбора статистики из 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 = "/opt/trueconf_bot/photo_bot/logs"
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(PHOTO_UNKNOWN_CMD_TEXT, parse_mode="html")
return
# --- ПРИЕМ НОВОГО ФОТО (ТЕСТОВЫЙ РЕЖИМ: ТОЛЬКО ИИ) ---
if is_attachment:
file_id = getattr(msg.content, "file_id", None)
raw_data = None
await msg.answer("🤖 <i>Отправка фото на проверку в ИИ (srvkem-ii-03:8083)...</i>", parse_mode="html")
try:
downloaded_file = await states.tc_bot.download_file_by_id(file_id)
raw_data = downloaded_file
# Отправляем фото напрямую в ИИ Gemma 12B Vision
ai_result = await verify_photo_with_ai(raw_data)
# 🛡️ Защита от неожиданного формата (гарантирует отсутствие NameError)
if not isinstance(ai_result, dict):
ai_result = {"is_valid": False, "reason": "Некорректный формат ответа от ИИ."}
is_valid = ai_result.get("is_valid", False)
reason = ai_result.get("reason", "Нет ответа от модели.")
if not is_valid:
await msg.answer(
f"❌ <b>[ИИ Отклонил]</b>\n\n<b>Причина:</b> {reason}",
parse_mode="html"
)
else:
await msg.answer(
f"✅ <b>[ИИ Одобрил]</b>\n\n<b>Вердикт:</b> Фотография полностью соответствует требованиям.",
parse_mode="html"
)
except Exception as e:
logger.exception(f"Ошибка при передаче фото в ИИ: {e}")
await handle_photo_error(msg, user_id, "Скачивание и обработка файла", str(e))
finally:
if raw_data and isinstance(raw_data, str) and os.path.exists(raw_data):
try:
os.remove(raw_data)
except Exception:
pass
return