From 5bd26d706e0de9cabadfe9faf082a089714dc5f8 Mon Sep 17 00:00:00 2001 From: dddennnisss Date: Fri, 14 Aug 2026 09:48:49 +0700 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=BB=D0=BD=D0=B0=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0:?= =?UTF-8?q?=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=81=20Django=20API,=20=D0=BF=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E,=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- photo_bot/ad_photo_bot.py | 447 +++++++++++++++++++++++--------------- 1 file changed, 266 insertions(+), 181 deletions(-) diff --git a/photo_bot/ad_photo_bot.py b/photo_bot/ad_photo_bot.py index c734cbe..78131db 100644 --- a/photo_bot/ad_photo_bot.py +++ b/photo_bot/ad_photo_bot.py @@ -1,181 +1,266 @@ -import os -import asyncio -import logging -import sys - -# Добавляем путь к корню проекта -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 photo_processor import prepare_ad_photo -from ad_helper import upload_photo_to_ad - -# Настройки -from config.config import * - -# --- НАСТРОЙКА ЛОГИРОВАНИЯ --- -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[ - logging.FileHandler("photo_bot_log.txt", encoding="utf-8"), - logging.StreamHandler() - ] -) -logger = logging.getLogger(__name__) - -router = Router() -tc_bot = None - -# 🧠 ПАМЯТЬ БОТА: Храним пути к фото, ожидающим подтверждения -# Пример: {'ds.krivochenko': '/tmp/ready_ds.krivochenko.jpg'} -pending_photos = {} - -@router.message() -async def handle_message(msg: Message): - global tc_bot - - # Определяем логин пользователя - user_login = msg.from_user.id.split('@')[0] if '@' in msg.from_user.id else msg.from_user.id - - # Проверяем, вложение ли это - is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT" - - # ===================================================================== - # СЦЕНАРИЙ 1: Бот ждет ответа, и пользователь написал текст (Да/Нет) - # ===================================================================== - if not is_attachment and user_login in pending_photos and msg.text: - answer = msg.text.strip().lower() - - # Если согласен - if answer in ["да", "yes", "+", "загрузить", "давай", "конечно"]: - photo_path = pending_photos[user_login] - await msg.answer("⏳ Подключаюсь к Active Directory и загружаю фото...") - - # Запускаем скрипт загрузки в AD - upload_result = await asyncio.to_thread(upload_photo_to_ad, user_login, photo_path) - - if upload_result["success"]: - await msg.answer( - "🎉 **Ура! Фото успешно установлено в Active Directory!**\n\n" - "Оно появится в Outlook, TrueConf и других системах в течение нескольких часов (после синхронизации серверов)." - ) - else: - await msg.answer(f"❌ **Ошибка при загрузке:** {upload_result.get('error')}") - - # Убираем за собой файл и стираем пользователя из памяти - if os.path.exists(photo_path): - os.remove(photo_path) - del pending_photos[user_login] - return - - # Если отказался - elif answer in ["нет", "no", "-", "отмена"]: - photo_path = pending_photos[user_login] - await msg.answer("🚫 Загрузка отменена. Можете прислать мне другую фотографию!") - - if os.path.exists(photo_path): - os.remove(photo_path) - del pending_photos[user_login] - return - - # Если написал что-то непонятное, но мы ждем ответа - else: - await msg.answer("❓ Пожалуйста, напишите **Да** (чтобы загрузить фото в AD) или **Нет** (чтобы отменить). Либо просто пришлите новую фотографию.") - return - - # ===================================================================== - # СЦЕНАРИЙ 2: Пользователь прислал новую фотографию - # ===================================================================== - if is_attachment: - file_id = getattr(msg.content, "file_id", None) - - if not file_id: - await msg.answer("❌ Не удалось получить файл. Попробуйте еще раз.") - return - - ready_path = f"/tmp/ready_{user_login}.jpg" - raw_path = None - - await msg.answer("⏳ Обрабатываю ваше фото, пожалуйста, подождите...") - - try: - downloaded_file = await tc_bot.download_file_by_id(file_id) - raw_path = downloaded_file - - # Обрабатываем фото (с новым разрешением 240x240) - result = await asyncio.to_thread(prepare_ad_photo, raw_path, ready_path, 240) - - if result["success"]: - photo_document = FSInputFile(ready_path, filename=f"AD_{user_login}.jpg") - - # Отправляем фото и СРАЗУ задаем вопрос - await msg.answer_photo( - photo_document, - photo_document, - caption=( - f"✅ **Фото готово!**\n\n" - f"📐 Размер: 240x240 px\n" - f"⚖️ Вес: {result['size_kb']} KB\n\n" - f"❓ **Вам нравится результат? Загружаем в Active Directory?**\n" - f"Напишите мне в ответ **Да** или **Нет**." - ) - ) - - # 🧠 ЗАПОМИНАЕМ, что этот пользователь теперь в режиме ожидания ответа - pending_photos[user_login] = ready_path - logger.info(f"Фото подготовлено для {user_login}, ждем подтверждения.") - - else: - await msg.answer(f"❌ Ошибка обработки: {result.get('error')}") - - except Exception as e: - logger.error(f"Ошибка при работе с фото для {user_login}: {e}") - await msg.answer("❌ Произошла системная ошибка при обработке.") - - finally: - # ВАЖНО: Удаляем ТОЛЬКО оригинал от TrueConf (raw_path). - # Готовое фото (ready_path) оставляем на диске, оно ждет ответа "Да"! - if raw_path and os.path.exists(raw_path): - try: os.remove(raw_path) - except: pass - - return - - # ===================================================================== - # СЦЕНАРИЙ 3: Дефолтное приветствие (если просто написали текст) - # ===================================================================== - await msg.answer( - "👋 Привет! Я корпоративный фотограф для Active Directory.\n\n" - "📸 **Просто отправь мне фото**, и я автоматически:\n" - "1. Найду лицо на снимке и сделаю правильный кадр.\n" - "2. Улучшу резкость и сожму до стандарта 240x240 px.\n" - "3. Предложу загрузить результат прямо в твой профиль!" - ) - -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("Бот остановлен пользователем.") \ No newline at end of file +import os +import asyncio +import logging +import sys +import io + +# Добавляем путь к корню проекта +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 photo_processor import prepare_ad_photo, get_crop_coordinates +from utils.texts import ( + PHOTO_MAIN_MENU_TEXT, + PHOTO_UNKNOWN_CMD_TEXT, + PHOTO_PROCESSING_FACE_TEXT, + PHOTO_UPLOADING_API_TEXT, + photo_preview_ready, + photo_success_published, + photo_success_pending, + photo_error_rejected, + photo_error_already_pending, + photo_cancel_text, + photo_error_handling, + EMOJI_DIGITS, +) + +# Настройки +from config.config import * + +# --- НАСТРОЙКА ЛОГИРОВАНИЯ --- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.FileHandler("photo_bot_log.txt", encoding="utf-8"), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +router = Router() +tc_bot = None + +# 🧠 ПАМЯТЬ БОТА: Храним пути к фото, ожидающим подтверждения +# Пример: {'ds.krivochenko': {'path': '/tmp/orig_abc.jpg', 'coords': (100, 50, 300, 400)}} +pending_photos = {} + + +def _generate_preview(image_bytes: bytes, coords: tuple, size: int = 400) -> bytes | None: + """Генерирует превью фото с кропом по координатам.""" + try: + from PIL import Image + img = Image.open(io.BytesIO(image_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((size, size), Image.Resampling.LANCZOS) + + buf = io.BytesIO() + preview.save(buf, format="JPEG", quality=85) + buf.seek(0) + return buf.getvalue() + except Exception as e: + logger.error(f"Ошибка генерации превью: {e}") + return None + + +@router.message() +async def handle_message(msg: Message): + global tc_bot + + # Определяем логин пользователя + user_login = msg.from_user.id.split('@')[0] if '@' in msg.from_user.id else msg.from_user.id + + # Проверяем, вложение ли это + is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT" + + # ===================================================================== + # СЦЕНАРИЙ 1: Бот ждет ответа, и пользователь написал текст (Да/Нет) + # ===================================================================== + if not is_attachment and user_login in pending_photos and msg.text: + answer = msg.text.strip().lower() + + # Если согласен + if answer in ["да", "yes", "+", "загрузить", "давай", "конечно"]: + photo_data = pending_photos[user_login] + original_path = photo_data['path'] + coords = photo_data['coords'] + + await msg.answer(PHOTO_UPLOADING_API_TEXT) + + try: + # Отправляем фото через API + result = await asyncio.to_thread( + prepare_ad_photo, original_path, user_login + ) + + if result["success"]: + verdict = result.get("verdict") + if verdict == "published": + await msg.answer(photo_success_published()) + elif verdict == "pending_moderation": + await msg.answer(photo_success_pending()) + else: + # Успех, но вердикт другой — показываем сообщение + msg_text = result.get("message", "Фото обработано") + await msg.answer(f"✅ {msg_text}") + else: + error = result.get("error", "Неизвестная ошибка") + if "уже есть фото" in error: + await msg.answer(photo_error_already_pending()) + elif "отклонено" in error.lower() or "не прошел" in error.lower(): + await msg.answer(photo_error_rejected(error)) + else: + await msg.answer(f"❌ Ошибка: {error}") + + except Exception as e: + logger.error(f"Ошибка отправки фото: {e}") + await msg.answer(photo_error_handling()) + + # Убираем за собой + if os.path.exists(original_path): + os.remove(original_path) + del pending_photos[user_login] + return + + # Если отказался + elif answer in ["нет", "no", "-", "отмена"]: + photo_data = pending_photos[user_login] + original_path = photo_data['path'] + + await msg.answer(photo_cancel_text()) + + if os.path.exists(original_path): + os.remove(original_path) + del pending_photos[user_login] + return + + # Если написал что-то непонятное + else: + await msg.answer( + f"❓ Пожалуйста, нажмите:\n" + f"1️⃣ — Подтвердить\n" + f"2️⃣ — Вырезать заново\n\n" + f"Или просто отправьте новое фото." + ) + return + + # ===================================================================== + # СЦЕНАРИЙ 2: Пользователь прислал новую фотографию + # ===================================================================== + if is_attachment: + file_id = getattr(msg.content, "file_id", None) + + if not file_id: + await msg.answer("❌ Не удалось получить файл. Попробуйте еще раз.") + return + + await msg.answer(PHOTO_PROCESSING_FACE_TEXT) + + try: + downloaded_file = await tc_bot.download_file_by_id(file_id) + raw_path = downloaded_file + + # Читаем байты + with open(raw_path, 'rb') as f: + image_bytes = f.read() + + # Ищем лицо и получаем координаты + 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("Бот остановлен пользователем.")