Полная переработка: интеграция с Django API, превью, кнопки подтверждения

This commit is contained in:
2026-08-14 09:48:49 +07:00
parent 5deb7ff689
commit 5bd26d706e
+266 -181
View File
@@ -1,181 +1,266 @@
import os import os
import asyncio import asyncio
import logging import logging
import sys import sys
import io
# Добавляем путь к корню проекта
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Добавляем путь к корню проекта
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from trueconf import Bot, Dispatcher, Router, Message
from trueconf.types import FSInputFile from trueconf import Bot, Dispatcher, Router, Message
from trueconf.types import FSInputFile
# Наши обработчики
from photo_processor import prepare_ad_photo # Наши обработчики
from ad_helper import upload_photo_to_ad from photo_processor import prepare_ad_photo, get_crop_coordinates
from utils.texts import (
# Настройки PHOTO_MAIN_MENU_TEXT,
from config.config import * PHOTO_UNKNOWN_CMD_TEXT,
PHOTO_PROCESSING_FACE_TEXT,
# --- НАСТРОЙКА ЛОГИРОВАНИЯ --- PHOTO_UPLOADING_API_TEXT,
logging.basicConfig( photo_preview_ready,
level=logging.INFO, photo_success_published,
format="%(asctime)s [%(levelname)s] %(message)s", photo_success_pending,
handlers=[ photo_error_rejected,
logging.FileHandler("photo_bot_log.txt", encoding="utf-8"), photo_error_already_pending,
logging.StreamHandler() photo_cancel_text,
] photo_error_handling,
) EMOJI_DIGITS,
logger = logging.getLogger(__name__) )
router = Router() # Настройки
tc_bot = None from config.config import *
# 🧠 ПАМЯТЬ БОТА: Храним пути к фото, ожидающим подтверждения # --- НАСТРОЙКА ЛОГИРОВАНИЯ ---
# Пример: {'ds.krivochenko': '/tmp/ready_ds.krivochenko.jpg'} logging.basicConfig(
pending_photos = {} level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
@router.message() handlers=[
async def handle_message(msg: Message): logging.FileHandler("photo_bot_log.txt", encoding="utf-8"),
global tc_bot logging.StreamHandler()
]
# Определяем логин пользователя )
user_login = msg.from_user.id.split('@')[0] if '@' in msg.from_user.id else msg.from_user.id logger = logging.getLogger(__name__)
# Проверяем, вложение ли это router = Router()
is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT" tc_bot = None
# ===================================================================== # 🧠 ПАМЯТЬ БОТА: Храним пути к фото, ожидающим подтверждения
# СЦЕНАРИЙ 1: Бот ждет ответа, и пользователь написал текст (Да/Нет) # Пример: {'ds.krivochenko': {'path': '/tmp/orig_abc.jpg', 'coords': (100, 50, 300, 400)}}
# ===================================================================== pending_photos = {}
if not is_attachment and user_login in pending_photos and msg.text:
answer = msg.text.strip().lower()
def _generate_preview(image_bytes: bytes, coords: tuple, size: int = 400) -> bytes | None:
# Если согласен """Генерирует превью фото с кропом по координатам."""
if answer in ["да", "yes", "+", "загрузить", "давай", "конечно"]: try:
photo_path = pending_photos[user_login] from PIL import Image
await msg.answer("⏳ Подключаюсь к Active Directory и загружаю фото...") img = Image.open(io.BytesIO(image_bytes))
if img.mode != 'RGB':
# Запускаем скрипт загрузки в AD img = img.convert('RGB')
upload_result = await asyncio.to_thread(upload_photo_to_ad, user_login, photo_path)
x1, y1, x2, y2 = coords
if upload_result["success"]: cropped = img.crop((x1, y1, x2, y2))
await msg.answer(
"🎉 **Ура! Фото успешно установлено в Active Directory!**\n\n" # Ресайз до квадрата
"Оно появится в Outlook, TrueConf и других системах в течение нескольких часов (после синхронизации серверов)." side = min(cropped.size[0], cropped.size[1])
) left = (cropped.size[0] - side) // 2
else: top = (cropped.size[1] - side) // 2
await msg.answer(f"❌ **Ошибка при загрузке:** {upload_result.get('error')}") cropped = cropped.crop((left, top, left + side, top + side))
# Убираем за собой файл и стираем пользователя из памяти preview = cropped.resize((size, size), Image.Resampling.LANCZOS)
if os.path.exists(photo_path):
os.remove(photo_path) buf = io.BytesIO()
del pending_photos[user_login] preview.save(buf, format="JPEG", quality=85)
return buf.seek(0)
return buf.getvalue()
# Если отказался except Exception as e:
elif answer in ["нет", "no", "-", "отмена"]: logger.error(f"Ошибка генерации превью: {e}")
photo_path = pending_photos[user_login] return None
await msg.answer("🚫 Загрузка отменена. Можете прислать мне другую фотографию!")
if os.path.exists(photo_path): @router.message()
os.remove(photo_path) async def handle_message(msg: Message):
del pending_photos[user_login] global tc_bot
return
# Определяем логин пользователя
# Если написал что-то непонятное, но мы ждем ответа user_login = msg.from_user.id.split('@')[0] if '@' in msg.from_user.id else msg.from_user.id
else:
await msg.answer("❓ Пожалуйста, напишите **Да** (чтобы загрузить фото в AD) или **Нет** (чтобы отменить). Либо просто пришлите новую фотографию.") # Проверяем, вложение ли это
return is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT"
# ===================================================================== # =====================================================================
# СЦЕНАРИЙ 2: Пользователь прислал новую фотографию # СЦЕНАРИЙ 1: Бот ждет ответа, и пользователь написал текст (Да/Нет)
# ===================================================================== # =====================================================================
if is_attachment: if not is_attachment and user_login in pending_photos and msg.text:
file_id = getattr(msg.content, "file_id", None) answer = msg.text.strip().lower()
if not file_id: # Если согласен
await msg.answer("❌ Не удалось получить файл. Попробуйте еще раз.") if answer in ["да", "yes", "+", "загрузить", "давай", "конечно"]:
return photo_data = pending_photos[user_login]
original_path = photo_data['path']
ready_path = f"/tmp/ready_{user_login}.jpg" coords = photo_data['coords']
raw_path = None
await msg.answer(PHOTO_UPLOADING_API_TEXT)
await msg.answer("⏳ Обрабатываю ваше фото, пожалуйста, подождите...")
try:
try: # Отправляем фото через API
downloaded_file = await tc_bot.download_file_by_id(file_id) result = await asyncio.to_thread(
raw_path = downloaded_file prepare_ad_photo, original_path, user_login
)
# Обрабатываем фото (с новым разрешением 240x240)
result = await asyncio.to_thread(prepare_ad_photo, raw_path, ready_path, 240) if result["success"]:
verdict = result.get("verdict")
if result["success"]: if verdict == "published":
photo_document = FSInputFile(ready_path, filename=f"AD_{user_login}.jpg") await msg.answer(photo_success_published())
elif verdict == "pending_moderation":
# Отправляем фото и СРАЗУ задаем вопрос await msg.answer(photo_success_pending())
await msg.answer_photo( else:
photo_document, # Успех, но вердикт другой — показываем сообщение
photo_document, msg_text = result.get("message", "Фото обработано")
caption=( await msg.answer(f"{msg_text}")
f"✅ **Фото готово!**\n\n" else:
f"📐 Размер: 240x240 px\n" error = result.get("error", "Неизвестная ошибка")
f"⚖️ Вес: {result['size_kb']} KB\n\n" if "уже есть фото" in error:
f"❓ **Вам нравится результат? Загружаем в Active Directory?**\n" await msg.answer(photo_error_already_pending())
f"Напишите мне в ответ **Да** или **Нет**." elif "отклонено" in error.lower() or "не прошел" in error.lower():
) await msg.answer(photo_error_rejected(error))
) else:
await msg.answer(f"❌ Ошибка: {error}")
# 🧠 ЗАПОМИНАЕМ, что этот пользователь теперь в режиме ожидания ответа
pending_photos[user_login] = ready_path except Exception as e:
logger.info(f"Фото подготовлено для {user_login}, ждем подтверждения.") logger.error(f"Ошибка отправки фото: {e}")
await msg.answer(photo_error_handling())
else:
await msg.answer(f"❌ Ошибка обработки: {result.get('error')}") # Убираем за собой
if os.path.exists(original_path):
except Exception as e: os.remove(original_path)
logger.error(f"Ошибка при работе с фото для {user_login}: {e}") del pending_photos[user_login]
await msg.answer("❌ Произошла системная ошибка при обработке.") return
finally: # Если отказался
# ВАЖНО: Удаляем ТОЛЬКО оригинал от TrueConf (raw_path). elif answer in ["нет", "no", "-", "отмена"]:
# Готовое фото (ready_path) оставляем на диске, оно ждет ответа "Да"! photo_data = pending_photos[user_login]
if raw_path and os.path.exists(raw_path): original_path = photo_data['path']
try: os.remove(raw_path)
except: pass await msg.answer(photo_cancel_text())
return if os.path.exists(original_path):
os.remove(original_path)
# ===================================================================== del pending_photos[user_login]
# СЦЕНАРИЙ 3: Дефолтное приветствие (если просто написали текст) return
# =====================================================================
await msg.answer( # Если написал что-то непонятное
"👋 Привет! Я корпоративный фотограф для Active Directory.\n\n" else:
"📸 **Просто отправь мне фото**, и я автоматически:\n" await msg.answer(
"1. Найду лицо на снимке и сделаю правильный кадр.\n" f"❓ Пожалуйста, нажмите:\n"
"2. Улучшу резкость и сожму до стандарта 240x240 px.\n" f"1️⃣ — Подтвердить\n"
"3. Предложу загрузить результат прямо в твой профиль!" f"2️⃣ — Вырезать заново\n\n"
) f"Или просто отправьте новое фото."
)
async def main(): return
global tc_bot
# =====================================================================
logger.info(f"Запуск фото-бота на сервере {TC_SERVER}...") # СЦЕНАРИЙ 2: Пользователь прислал новую фотографию
# =====================================================================
dp = Dispatcher() if is_attachment:
dp.include_router(router) file_id = getattr(msg.content, "file_id", None)
tc_bot = Bot.from_credentials( if not file_id:
server=TC_SERVER, await msg.answer("❌ Не удалось получить файл. Попробуйте еще раз.")
username=TC_LOGIN, return
password=TC_PASSWORD,
dispatcher=dp, await msg.answer(PHOTO_PROCESSING_FACE_TEXT)
verify_ssl=False
) try:
downloaded_file = await tc_bot.download_file_by_id(file_id)
await tc_bot.run() raw_path = downloaded_file
if __name__ == "__main__": # Читаем байты
try: with open(raw_path, 'rb') as f:
asyncio.run(main()) image_bytes = f.read()
except KeyboardInterrupt:
logger.info("Бот остановлен пользователем.") # Ищем лицо и получаем координаты
coords = await asyncio.to_thread(get_crop_coordinates, image_bytes)
if not coords:
await msg.answer(
"❌ Лицо не найдено. Пожалуйста, сделайте фото анфас при хорошем освещении.\n\n"
f"{EMOJI_DIGITS['0']}В главное меню"
)
if os.path.exists(raw_path):
os.remove(raw_path)
return
# Генерируем превью
preview_bytes = _generate_preview(image_bytes, coords, size=400)
if preview_bytes:
# Сохраняем оригинал и координаты
preview_path = f"/tmp/preview_{user_login}.jpg"
with open(preview_path, 'wb') as f:
f.write(preview_bytes)
# Сохраняем оригинал
original_path = f"/tmp/orig_{user_login}.jpg"
with open(original_path, 'wb') as f:
f.write(image_bytes)
# Отправляем превью
preview_doc = FSInputFile(preview_path, filename=f"preview_{user_login}.jpg")
await msg.answer_photo(
preview_doc, preview_doc,
caption=photo_preview_ready()
)
# Сохраняем состояние
pending_photos[user_login] = {
'path': original_path,
'coords': coords,
'preview_path': preview_path
}
logger.info(f"Фото подготовлено для {user_login}, координаты: {coords}")
else:
await msg.answer("❌ Ошибка обработки фото. Попробуйте снова.")
except Exception as e:
logger.error(f"Ошибка при работе с фото для {user_login}: {e}")
await msg.answer(photo_error_handling())
finally:
# Удаляем оригинал TrueConf, но не превью (оно в pending_photos)
if 'raw_path' in locals() and os.path.exists(raw_path):
try:
os.remove(raw_path)
except:
pass
return
# =====================================================================
# СЦЕНАРИЙ 3: Дефолтное приветствие (если просто написали текст)
# =====================================================================
await msg.answer(PHOTO_MAIN_MENU_TEXT)
async def main():
global tc_bot
logger.info(f"Запуск фото-бота на сервере {TC_SERVER}...")
dp = Dispatcher()
dp.include_router(router)
tc_bot = Bot.from_credentials(
server=TC_SERVER,
username=TC_LOGIN,
password=TC_PASSWORD,
dispatcher=dp,
verify_ssl=False
)
await tc_bot.run()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Бот остановлен пользователем.")