253 lines
11 KiB
Python
253 lines
11 KiB
Python
# /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"<b>❌ Фотография не прошла проверку</b>\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
|
|
|