551 lines
27 KiB
Python
551 lines
27 KiB
Python
# /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 (
|
|
UNKNOWN_MAIN_CMD_TEXT,
|
|
EMOJI_DIGITS,
|
|
PHOTO_MAIN_MENU_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(UNKNOWN_MAIN_CMD_TEXT, 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(UNKNOWN_MAIN_CMD_TEXT, 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(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
|
return
|
|
|
|
# --- НЕВЕРНАЯ КОМАНДА (висящего фото нет) → глобальное сообщение ---
|
|
if not is_attachment:
|
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
|
return
|
|
|
|
# --- ФОТО НА МОДЕРАЦИИ: в обычном режиме новое фото не принимается ---
|
|
if is_attachment and not in_test_submode(user_id) and user_id in moderation_pending_users:
|
|
status = await check_moderation_status(user_id)
|
|
if status.get("status") == "ok" and status.get("moderation_status") == "pending":
|
|
log_menu_stats(user_id, "Photo Bot", "Отказ в приёме фото — есть фото на модерации")
|
|
await msg.answer(
|
|
photo_welcome_with_status(status, show_test=config.PHOTO_TEST_MODE),
|
|
parse_mode="html"
|
|
)
|
|
return
|
|
# Статус изменился (одобрено/отклонено) — снимаем блок, фото принимается штатно
|
|
moderation_pending_users.discard(user_id)
|
|
|
|
# --- ПРИЕМ НОВОГО ФОТО ---
|
|
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
|