56 Commits

Author SHA1 Message Date
dddennnisss 612564389b Тест-режим: отправлять на /check/ авто-кроп лица (как в превью/публикации), а не оригинал
- make_face_crop_bytes(): тот же квадратный кроп по центру лица, что
  превью и публикуемый кадр (полное разрешение, JPEG q90)
- handlers: кнопка 1 шлёт на проверку кроп, не оригинал
- правило «голова >= 30%» теперь меряется по публикуемому результату
2026-08-23 15:06:04 +07:00
dddennnisss b5cd3df5dd Тест-режим: показывать метрики кадра из /check/ (какой кадр измерил сайт) 2026-08-23 04:33:19 +07:00
dddennnisss a0ade670fe Тест-режим: сброс протухшего pending_photos/test_submode при входе в модуль (фото больше не улетает сразу после тайм-аута) 2026-08-23 03:36:22 +07:00
dddennnisss 2a4e2fc9a2 Fix: URL /check/ (было photo-botcheck/ — терялся слэш после replace) 2026-08-22 21:27:52 +07:00
dddennnisss f18c564ead Тест-режим: проверка через /api/photo-bot/check/ сайта (CV+гримасы+ИИ), метрики из llama.cpp, удаление прямого llama.cpp-клиента 2026-08-22 20:47:57 +07:00
dddennnisss ab58203982 Фото: запрет приёма фото при «на модерации»; неверная команда — «Неизвестная команда» (UNKNOWN_MAIN_CMD_TEXT) во всём модуле 2026-08-22 16:05:08 +07:00
dddennnisss 859ac42a77 Фото: неверная команда — глобальное сообщение ошибки; тест-режим: кнопка 9 «Назад» 2026-08-22 15:24:42 +07:00
dddennnisss bd0c14c8f0 Фото: pending — только статус (без «пришлите фото», без номера); тест-приветствие без блока модерации 2026-08-22 14:48:55 +07:00
dddennnisss 8a7634daed Фото: вход в тест-режим только по кнопке 1; во входном сообщении — меню+статус+кнопка 1 (без описания тест-режима) 2026-08-22 14:22:00 +07:00
dddennnisss 7fa515de58 Фото (тест-режим): детальный отчёт ИИ — время, токены (приём/отдача), скорость ток/сек, детали ошибок 2026-08-22 10:59:29 +07:00
dddennnisss e08c861149 Фото: pending-состояние — модуль остаётся открыт; тест-режим: фото можно отправить на тест; прочий ввод → ошибка неверного выбора 2026-08-22 00:08:34 +07:00
dddennnisss 1bdfd163fe Фото: проверка статуса внутри режима тоже работает в тестовом режиме 2026-08-21 22:25:19 +07:00
dddennnisss 0b9a697099 Фото: статусы модерации показываются и в тестовом режиме 2026-08-21 22:22:42 +07:00
dddennnisss 1c2d018be4 Фото: входное сообщение универсальное (меню + статус модерации одним сообщением) 2026-08-21 22:08:34 +07:00
dddennnisss 0b2364d79f Фото: проверка статуса модерации перенесена из main.py в модуль photo_bot (паттерн ЛК) 2026-08-21 21:21:14 +07:00
dddennnisss c358d4cbbb Фото: уведомление о причине отказа модерации при входе в модуль 2026-08-21 20:05:18 +07:00
Hermes Bot 1ea42a9197 Тест-мод: под-режим «1 — тест проверка фото» внутри штатного фото-режима 2026-08-20 13:06:35 +07:00
Denis Krivochenko 633eb27450 config: фото-тест-мод — без .env, всё в константах config.py 2026-08-17 17:22:01 +07:00
Denis Krivochenko 9271c23780 photo_bot: разрешение фото для ИИ 768->1536 (PHOTO_TEST_AI_MAX_SIDE) 2026-08-17 17:17:02 +07:00
Denis Krivochenko 0941a28da4 photo_bot: жёсткий промпт — чек-лист A-E, фиксация мимики/графики, правила отказа 2026-08-17 17:10:37 +07:00
Denis Krivochenko d3b1ba6e8a prompt: стоп-фактор «гримасы и мимика» + фон с буквами/коллажи 2026-08-17 16:54:55 +07:00
Denis Krivochenko 9cb234dda3 photo_bot: текст превью тест-мода — кнопки 1/2/0 2026-08-17 16:45:45 +07:00
Denis Krivochenko 6f4c979567 config: PHOTO_TEST_MODE — прямой флаг True/False без .env 2026-08-17 16:28:31 +07:00
Denis Krivochenko 9f49dd3e96 photo_bot: тест-мод — текст поиска лица 2026-08-17 16:04:54 +07:00
Denis Krivochenko aee2fe8f25 photo_bot: тестовый мод — фото напрямую в ИИ, без публикации 2026-08-17 16:03:04 +07:00
dddennnisss 9f35db6f09 main.py: обработка 0 и меню — возврат в главное меню 2026-08-14 14:57:20 +07:00
dddennnisss f90dd77bd8 Обновить utils/texts.py 2026-08-14 14:55:24 +07:00
dddennnisss a98fcd0feb photo_processor.py: извлекаем логин из email 2026-08-14 14:41:30 +07:00
dddennnisss b065a85114 handlers.py: извлекаем логин из email 2026-08-14 14:41:22 +07:00
dddennnisss 795f037f1b handlers.py: извлекаем логин из email 2026-08-14 14:39:30 +07:00
dddennnisss 7ca86466f7 main.py: проверка модерации при входе в PHOTO_MODE 2026-08-14 14:35:05 +07:00
dddennnisss 966b8d4774 handlers.py: photo_moderation_pending из texts.py 2026-08-14 14:13:33 +07:00
dddennnisss 7aa2d92b8b texts.py: добавлена photo_moderation_pending, убраны дубликаты 2026-08-14 14:09:16 +07:00
dddennnisss 4418bcb3a3 handlers.py: проверка статуса модерации при входе и перед отправкой 2026-08-14 13:50:46 +07:00
dddennnisss 7c74c19de6 handlers.py: убран автопереход в меню после отправки фото 2026-08-14 13:25:59 +07:00
dddennnisss 781f52a301 handlers.py: убран PHOTO_UPLOADING_API_TEXT, оставлен PHOTO_AI_PROCESSING_TEXT 2026-08-14 12:59:24 +07:00
dddennnisss 90777785f4 texts.py: убран PHOTO_UPLOADING_API_TEXT, оставлен PHOTO_AI_PROCESSING_TEXT 2026-08-14 12:58:57 +07:00
dddennnisss 182194a57a photo_processor.py: timeout 300с (5 минут) 2026-08-14 12:41:05 +07:00
dddennnisss 3148a6c5ab handlers.py: PHOTO_AI_PROCESSING_TEXT из utils/texts.py 2026-08-14 12:40:44 +07:00
dddennnisss 20759ac600 texts.py: добавлен PHOTO_AI_PROCESSING_TEXT (5 минут) 2026-08-14 12:39:02 +07:00
dddennnisss 5644e6dc74 texts.py: добавлен PHOTO_AI_PROCESSING_TEXT (5 минут) 2026-08-14 12:38:33 +07:00
dddennnisss 0fd2136bc0 handlers.py: PHOTO_AI_PROCESSING_TEXT из utils/texts.py 2026-08-14 12:37:35 +07:00
dddennnisss d6c7d1258d texts.py: добавлен PHOTO_AI_PROCESSING_TEXT (5 минут) 2026-08-14 12:37:15 +07:00
dddennnisss 1990b915b2 texts.py: добавлен PHOTO_AI_PROCESSING_TEXT (5 минут) 2026-08-14 12:36:40 +07:00
dddennnisss 2be4711a3a photo_processor.py: timeout 120с для ИИ 2026-08-14 12:28:38 +07:00
dddennnisss 0dba3e7bf6 photo_processor.py: port 8085 добавлен 2026-08-14 12:20:34 +07:00
dddennnisss 746d9bc7e4 handlers.py: все техошибки через photo_error_handling() 2026-08-14 12:14:59 +07:00
dddennnisss c0ec03526e photo_processor.py: логгирование ответа API 2026-08-14 12:12:41 +07:00
dddennnisss 0cf46713d4 photo_processor.py: verify=False для SSL cert 2026-08-14 12:02:08 +07:00
dddennnisss 55d95d3042 handlers.py: NameError image_bytes->raw_bytes исправлено 2026-08-14 11:51:56 +07:00
dddennnisss 7196fe603a handlers.py: добавлен import smtplib, детальное логгирование ошибок 2026-08-14 11:49:25 +07:00
dddennnisss ff6216cdf3 Фикс: поддержка WebP через PIL fallback 2026-08-14 11:41:48 +07:00
dddennnisss a38fc7d889 handlers.py: полная переработка под Django API, get_crop_coordinates, превью 2026-08-14 09:57:52 +07:00
dddennnisss 5bd26d706e Полная переработка: интеграция с Django API, превью, кнопки подтверждения 2026-08-14 09:48:49 +07:00
dddennnisss 5deb7ff689 Обновлены тексты PHOTO BOT — интеграция с Django API 2026-08-14 09:43:41 +07:00
dddennnisss 707dcf3437 Рефакторинг: выделение get_crop_coordinates(), интеграция с Django API 2026-08-14 09:37:35 +07:00
13 changed files with 1179 additions and 387 deletions
Binary file not shown.
Binary file not shown.
+12
View File
@@ -88,6 +88,18 @@ ENABLE_SEARCH_BOT = True # Модуль интеллектуальног
ENABLE_INSTRUCT = True # Модуль инструкций (Trueconf, Почта)
ENABLE_LK = True # Личный кабинет сотрудника (отпуска, расчетные листки, справки)
# =========================================================
# 1.1 ТЕСТОВЫЙ ПОД-РЕЖИМ ФОТО-БОТА («1 — тест проверка фото»)
# При включённом флаге в меню «Режим обновления фото профиля»
# появляется пункт «1 — тест проверка фото». Внутри под-режима
# фото уходит НАПРЯМУЮ в ИИ (llama.cpp, тот же endpoint и промпт,
# что в SibCemProfile/PhotoApp/photo_validator.py), результат
# показывается пользователю. Само фото никуда НЕ ДОСЫЛАЕТСЯ
# (ни в AD, ни в API, ни на модерацию). Штатный поток
# (фото → превью → публикация) работает параллельно.
# =========================================================
PHOTO_TEST_MODE = False # True — в меню фото-режима доступен пункт «тест проверка фото»
# =========================================================
# 2. КОНТРОЛЬ ДОСТУПА
# =========================================================
+6 -3
View File
@@ -110,6 +110,10 @@ async def main_menu(msg: Message):
menu_shown = USER_MENU_SHOWN.get(user_id, False)
if msg.text:
cmd = msg.text.strip().lower()
# Обработка "0" — возврат в главное меню
if cmd in ("0", "меню"):
await msg.answer(MENU_TEXT, parse_mode="html")
return
action = MENU_MAP.get(cmd)
if action == "SD":
USER_MENU_SHOWN[user_id] = True
@@ -119,9 +123,8 @@ async def main_menu(msg: Message):
return
elif action == "PHOTO":
USER_MENU_SHOWN[user_id] = True
set_state(user_id, "PHOTO_MODE")
log_menu_stats(user_id, "Photo Bot", "Вход")
await msg.answer(PHOTO_MAIN_MENU_TEXT, parse_mode="html")
from photo_bot.handlers import send_dynamic_photo_main_menu
await send_dynamic_photo_main_menu(msg, user_id)
return
elif action == "TRANSCRIPTION":
USER_MENU_SHOWN[user_id] = True
Binary file not shown.
Binary file not shown.
+143 -58
View File
@@ -2,6 +2,7 @@ import os
import asyncio
import logging
import sys
import io
# Добавляем путь к корню проекта
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -10,8 +11,21 @@ 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,
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 *
@@ -31,9 +45,38 @@ router = Router()
tc_bot = None
# 🧠 ПАМЯТЬ БОТА: Храним пути к фото, ожидающим подтверждения
# Пример: {'ds.krivochenko': '/tmp/ready_ds.krivochenko.jpg'}
# Пример: {'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
@@ -52,39 +95,67 @@ async def handle_message(msg: Message):
# Если согласен
if answer in ["да", "yes", "+", "загрузить", "давай", "конечно"]:
photo_path = pending_photos[user_login]
await msg.answer("⏳ Подключаюсь к Active Directory и загружаю фото...")
photo_data = pending_photos[user_login]
original_path = photo_data['path']
coords = photo_data['coords']
# Запускаем скрипт загрузки в AD
upload_result = await asyncio.to_thread(upload_photo_to_ad, user_login, photo_path)
await msg.answer(PHOTO_UPLOADING_API_TEXT)
if upload_result["success"]:
await msg.answer(
"🎉 **Ура! Фото успешно установлено в Active Directory!**\n\n"
"Оно появится в Outlook, TrueConf и других системах в течение нескольких часов (после синхронизации серверов)."
try:
# Отправляем фото через API
result = await asyncio.to_thread(
prepare_ad_photo, original_path, user_login
)
else:
await msg.answer(f"❌ **Ошибка при загрузке:** {upload_result.get('error')}")
# Убираем за собой файл и стираем пользователя из памяти
if os.path.exists(photo_path):
os.remove(photo_path)
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_path = pending_photos[user_login]
await msg.answer("🚫 Загрузка отменена. Можете прислать мне другую фотографию!")
photo_data = pending_photos[user_login]
original_path = photo_data['path']
if os.path.exists(photo_path):
os.remove(photo_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("❓ Пожалуйста, напишите **Да** (чтобы загрузить фото в AD) или **Нет** (чтобы отменить). Либо просто пришлите новую фотографию.")
await msg.answer(
f"❓ Пожалуйста, нажмите:\n"
f"1️⃣ — Подтвердить\n"
f"2️⃣ — Вырезать заново\n\n"
f"Или просто отправьте новое фото."
)
return
# =====================================================================
@@ -97,64 +168,78 @@ async def handle_message(msg: Message):
await msg.answer("❌ Не удалось получить файл. Попробуйте еще раз.")
return
ready_path = f"/tmp/ready_{user_login}.jpg"
raw_path = None
await msg.answer("⏳ Обрабатываю ваше фото, пожалуйста, подождите...")
await msg.answer(PHOTO_PROCESSING_FACE_TEXT)
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)
# Читаем байты
with open(raw_path, 'rb') as f:
image_bytes = f.read()
if result["success"]:
photo_document = FSInputFile(ready_path, filename=f"AD_{user_login}.jpg")
# Ищем лицо и получаем координаты
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(
photo_document,
photo_document,
caption=(
f"✅ **Фото готово!**\n\n"
f"📐 Размер: 240x240 px\n"
f"⚖️ Вес: {result['size_kb']} KB\n\n"
f"❓ **Вам нравится результат? Загружаем в Active Directory?**\n"
f"Напишите мне в ответ **Да** или **Нет**."
)
preview_doc, preview_doc,
caption=photo_preview_ready()
)
# 🧠 ЗАПОМИНАЕМ, что этот пользователь теперь в режиме ожидания ответа
pending_photos[user_login] = ready_path
logger.info(f"Фото подготовлено для {user_login}, ждем подтверждения.")
# Сохраняем состояние
pending_photos[user_login] = {
'path': original_path,
'coords': coords,
'preview_path': preview_path
}
logger.info(f"Фото подготовлено для {user_login}, координаты: {coords}")
else:
await msg.answer(f"❌ Ошибка обработки: {result.get('error')}")
await msg.answer("❌ Ошибка обработки фото. Попробуйте снова.")
except Exception as e:
logger.error(f"Ошибка при работе с фото для {user_login}: {e}")
await msg.answer("❌ Произошла системная ошибка при обработке.")
await msg.answer(photo_error_handling())
finally:
# ВАЖНО: Удаляем ТОЛЬКО оригинал от TrueConf (raw_path).
# Готовое фото (ready_path) оставляем на диске, оно ждет ответа "Да"!
if raw_path and os.path.exists(raw_path):
try: os.remove(raw_path)
except: pass
# Удаляем оригинал 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(
"👋 Привет! Я корпоративный фотограф для Active Directory.\n\n"
"📸 **Просто отправь мне фото**, и я автоматически:\n"
"1. Найду лицо на снимке и сделаю правильный кадр.\n"
"2. Улучшу резкость и сожму до стандарта 240x240 px.\n"
"3. Предложу загрузить результат прямо в твой профиль!"
)
await msg.answer(PHOTO_MAIN_MENU_TEXT)
async def main():
global tc_bot
+403 -65
View File
@@ -2,7 +2,6 @@
import os
import asyncio
import logging
import httpx
import smtplib
import time
from datetime import datetime
@@ -15,18 +14,27 @@ 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
from photo_bot.photo_processor import prepare_ad_photo, get_crop_coordinates, check_photo_api, make_face_crop_bytes
from utils.texts import (
UNKNOWN_MAIN_CMD_TEXT,
EMOJI_DIGITS,
PHOTO_MAIN_MENU_TEXT,
system_error_text,
photo_error_handling,
PHOTO_UPLOADING_TEXT,
photo_success_sent,
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_PROCESSING_TEXT,
photo_preview_ready,
photo_error_processing,
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
@@ -36,8 +44,23 @@ 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"
# =========================================================
# СИСТЕМА ОБРАБОТКИ ОШИБОК И ЛОГИРОВАНИЯ
# =========================================================
@@ -101,11 +124,95 @@ def log_and_notify_photo_error(user_id: str, action: str, error_details: str):
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 — штатное меню входа.
Статус проверяется и показывается всегда, включая тестовый режим.
"""
# Новый вход в модуль (кнопка 4 из главного меню) = свежий сеанс.
# Тайм-аут сессии чистит только user_states, а module-level pending_photos
# и test_submode_users остаются: висящий тест-кадр из прошлой сессии иначе
# улетит на проверку сразу после нажатия «1». Сбрасываем здесь.
if user_id in pending_photos:
_pd = pending_photos[user_id]
for _k in ('path', 'preview_path'):
_p = _pd.get(_k)
if _p and os.path.exists(_p):
try:
os.remove(_p)
except OSError:
pass
del pending_photos[user_id]
test_submode_users.discard(user_id)
# Статус модерации запрашиваем всегда (и в тестовом режиме тоже)
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")
# =========================================================
# ОСНОВНОЙ ОБРАБОТЧИК
# =========================================================
@@ -131,112 +238,343 @@ async def photo_module_handler(msg: Message):
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:
if os.path.exists(pending_photos[user_id]):
os.remove(pending_photos[user_id])
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
# --- ПОДТВЕРЖДЕНИЕ ОТПРАВКИ (Кнопка 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"
# --- СОСТОЯНИЕ «ФОТО НА МОДЕРАЦИИ»: активного фото нет ---
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 — пропускаем: фото обрабатывается штатно ниже
# (тест-под-режим: превью + отправка в ИИ)
await msg.answer(PHOTO_UPLOADING_TEXT, parse_mode="html")
# --- ПРОВЕРКА СТАТУСА: если нет фото в 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:
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)
with open(original_path, 'rb') as f:
raw_photo = f.read()
# На проверку уходит АВТО-КРОП ЛИЦА (тот же, что в превью
# и что публикуется на сайте) — правила «голова ≥ 30%» и
# прочее имеют смысл для публикуемого результата, а не
# для произвольно снятого оригинала.
crop_bytes = await asyncio.to_thread(
make_face_crop_bytes, raw_photo, photo_data.get('coords')
)
# Dry-run: полный пайплайн проверки на САЙТЕ (CV + гримасы + ИИ),
# фото в БД/AD/Orion не попадает
check_res = await asyncio.to_thread(check_photo_api, crop_bytes)
except Exception as e:
logger.error(f"🔥 [TEST MODE] Ошибка проверки фото: {e}")
check_res = {"status": "error", "reason": f"Ошибка бота: {e}"}
if response.status_code == 200:
# ФИКСАЦИЯ СТАТИСТИКИ: Успешная выгрузка фото на утверждение
log_menu_stats(user_id, "Photo Bot", "Отправка фото на утверждение")
await msg.answer(photo_success_sent(), parse_mode="html")
if check_res.get("status") != "ok":
# Фото остаётся в pending — можно повторить проверку (кнопка 1)
log_menu_stats(user_id, "Photo Bot (TEST)", f"Ошибка проверки: {check_res.get('reason')}")
await msg.answer(
photo_test_ai_error(check_res.get('reason', 'Неизвестная ошибка')),
parse_mode="html"
)
return
verdict = check_res.get('verdict', '')
log_menu_stats(user_id, "Photo Bot (TEST)", f"Вердикт проверки: {verdict}")
# Фото остаётся в pending — можно повторить проверку другим фото (кнопка 2)
await msg.answer(
photo_test_result(
verdict,
check_res.get('ai_reason') or '',
check_res.get('cv_approved'),
check_res.get('cv_reason') or '',
check_res.get('forced_moderation'),
check_res.get('timings'),
check_res.get('ai_metrics'),
frame=check_res.get('frame'),
),
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 os.path.exists(photo_path):
os.remove(photo_path)
if user_id in pending_photos:
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)
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(system_error_text(EMOJI_DIGITS), parse_mode="html")
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)
ready_path = f"/tmp/{user_login}.bmp"
raw_path = None
raw_bytes = None
await msg.answer(PHOTO_PROCESSING_TEXT, parse_mode="html")
await msg.answer(PHOTO_PROCESSING_FACE_TEXT, parse_mode="html")
try:
downloaded_file = await states.tc_bot.download_file_by_id(file_id)
raw_path = downloaded_file
result = await asyncio.to_thread(prepare_ad_photo, raw_path, ready_path, 96)
logger.info(f"Скачан файл для {user_login}: type={type(downloaded_file)}, len={len(downloaded_file) if isinstance(downloaded_file, (bytes, str)) else 'N/A'}")
if result["success"]:
# ФИКСАЦИЯ СТАТИСТИКИ: Факт успешной загрузки и обработки нового фото
log_menu_stats(user_id, "Photo Bot", "Загрузка и конвертация нового фото")
pending_photos[user_id] = ready_path
preview_path = f"/tmp/prev_{user_login}.jpg"
# Скачиваем в байты напрямую
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:
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)
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))
finally:
if os.path.exists(preview_path):
os.remove(preview_path)
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"
)
else:
error_text = result.get('error', 'Не удалось обработать фото.')
await msg.answer(
photo_error_processing(error_text),
parse_mode="html"
)
except Exception as e:
logger.error(f"🔥 Полная ошибка: {e}", exc_info=True)
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
+217 -88
View File
@@ -1,84 +1,65 @@
import io
import cv2
import os
import logging
import numpy as np
from PIL import Image, ImageFilter, ImageEnhance
import requests
from PIL import Image
logger = logging.getLogger(__name__)
def prepare_ad_photo(input_path: str, output_path: str, target_size: int = 96) -> dict:
# Настройки API
API_URL = "https://profile.sibcem.ru:8085/SibCemProfile/api/photo-bot/upload/"
API_KEY = "sibcem_bot_2026_a7f3k9m2x8q1w5e4r6t"
def _image_to_cv2(image_bytes: bytes):
"""Конвертирует байты изображения в OpenCV матрицу (поддержка webp)."""
# Сначала пробуем cv2.imdecode
nparr = np.frombuffer(image_bytes, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img is not None:
return img
# Если не сработало, пробуем через PIL (для webp и других форматов)
try:
# 1. Загружаем изображение для OpenCV
if isinstance(input_path, bytes):
img = cv2.imdecode(np.frombuffer(input_path, np.uint8), cv2.IMREAD_COLOR)
else:
img = cv2.imread(input_path)
pil_img = Image.open(io.BytesIO(image_bytes))
if pil_img.mode != 'RGB':
pil_img = pil_img.convert('RGB')
img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
return img
except Exception as e:
logger.error(f"Не удалось открыть изображение: {e}")
return None
def get_crop_coordinates(image_bytes: bytes) -> tuple | None:
"""
Ищет лицо на фото и возвращает координаты квадратного кропа.
Returns:
(crop_x1, crop_y1, crop_x2, crop_y2) или None если лицо не найдено.
"""
try:
img = _image_to_cv2(image_bytes)
if img is None:
return {"success": False, "error": "Не удалось прочитать файл изображения."}
if isinstance(input_path, bytes):
img = cv2.imdecode(np.frombuffer(input_path, np.uint8), cv2.IMREAD_COLOR)
else:
img = cv2.imread(input_path)
if img is None:
return {"success": False, "error": "Не удалось прочитать файл изображения."}
logger.warning("Не удалось декодировать изображение")
return None
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 2. Загружаем каскад Хаара для поиска лица
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
face_cascade = cv2.CascadeClassifier(cascade_path)
# 3. Ищем лица
# Оставляем оптимальное значение minNeighbors=8 для баланса
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=8, minSize=(50, 50))
if len(faces) == 0:
return {"success": False, "error": "Лицо не найдено! Пожалуйста, сделайте фото анфас при хорошем освещении."}
logger.warning("Лицо не найдено на фото")
return None
if len(faces) > 1:
faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True)
x, y, w, h = faces[0]
# =========================================================
# АБСОЛЮТНАЯ ЗАЩИТА ОТ ГРАФИКИ (НОРМАЛИЗОВАННАЯ МАТЕМАТИКА)
# =========================================================
# 1. Вырезаем центральную зону лица
face_roi_original = gray[y + int(h*0.2):y + int(h*0.8), x + int(w*0.2):x + int(w*0.8)]
# 2. Сжимаем до 100x100 пикселей.
# Это уравнивает 4K-фотографии и мелкие картинки, делая метрики стабильными.
face_roi = cv2.resize(face_roi_original, (100, 100))
std_dev = np.std(face_roi)
laplacian_var = cv2.Laplacian(face_roi, cv2.CV_64F).var()
unique_colors = len(np.unique(face_roi))
logger.info(f"Анализ нормализованного лица: std_dev={std_dev:.2f}, laplacian={laplacian_var:.2f}, colors={unique_colors}")
# ПРОВЕРКА 1: Плоские, замыленные картинки
if std_dev < 10.0 or laplacian_var < 30.0:
logger.warning(f"Заблокирован плоский рисунок/замыливание: std={std_dev:.1f}, lap={laplacian_var:.1f}")
return {"success": False, "error": "Изображение слишком размыто или имеет плоскую текстуру. Отправьте реальное фото."}
# ПРОВЕРКА 2: Векторы, комиксы и жесткий 2D-арт (Умный порог резкости + цвета)
# У реального человека на хорошую камеру laplacian может доходить до 500-600,
# но при этом всегда будет богатая палитра цветов (шум матрицы).
# У мультиков высокая резкость всегда сопровождается бедной палитрой.
if laplacian_var > 850.0 or (laplacian_var > 400.0 and unique_colors < 150):
logger.warning(f"Заблокирован резкий 2D-арт: lap={laplacian_var:.1f}, colors={unique_colors}")
return {"success": False, "error": "Изображение похоже на графический арт или сильно отфильтровано. Пожалуйста, загрузите естественное фото."}
# ПРОВЕРКА 3: 3D-рендеры и нейросети (Мало шума)
# На реальной фотографии (даже с хорошим светом) шум матрицы телефона
# даст минимум 110-120 уникальных оттенков на 10000 пикселей.
if unique_colors < 105:
logger.warning(f"Заблокирован 3D-рендер по палитре: unique_colors={unique_colors}")
return {"success": False, "error": "Изображение распознано как 3D-модель или рисунок (неестественная палитра). Ждем реальное фото!"}
# =========================================================
# 4. Кадрируем (делаем квадрат с отступами вокруг лица)
margin = int(w * 0.3)
y1 = max(0, y - int(margin * 1.5))
y2 = min(img.shape[0], y + h + margin)
@@ -92,36 +73,184 @@ def prepare_ad_photo(input_path: str, output_path: str, target_size: int = 96) -
crop_x2 = crop_x1 + side
crop_y2 = crop_y1 + side
# 5. Обработка через Pillow
pil_img = Image.open(io.BytesIO(input_path))
# ВАЖНО: Убираем альфа-канал (прозрачность), иначе BMP выдаст ошибку
if pil_img.mode in ('RGBA', 'P', 'LA'):
background = Image.new('RGB', pil_img.size, (255, 255, 255))
background.paste(pil_img, mask=pil_img.split()[3] if pil_img.mode == 'RGBA' else None)
pil_img = background
elif pil_img.mode != 'RGB':
pil_img = pil_img.convert('RGB')
cropped_img = pil_img.crop((crop_x1, crop_y1, crop_x2, crop_y2))
# Сжимаем до нужного размера (96x96)
final_img = cropped_img.resize((target_size, target_size), Image.Resampling.LANCZOS)
# Добавляем немного резкости, чтобы лицо не мылилось при 96px
final_img = final_img.filter(ImageFilter.UnsharpMask(radius=1.5, percent=100, threshold=3))
# 6. Сохраняем в формате BMP
final_img.save(output_path, format="BMP")
# 7. Проверяем финальный вес
size_kb = os.path.getsize(output_path) / 1024
if size_kb > 99:
return {"success": False, "error": f"Сбой: размер файла ({size_kb:.1f} KB) превысил лимит в 99 KB."}
return {"success": True, "size_kb": round(size_kb, 1)}
logger.info(f"Лицо найдено: ({x},{y},{w},{h}), кроп: ({crop_x1},{crop_y1},{crop_x2},{crop_y2})")
return (crop_x1, crop_y1, crop_x2, crop_y2)
except Exception as e:
logger.error(f"Ошибка в photo_processor: {e}")
logger.error(f"Ошибка в get_crop_coordinates: {e}", exc_info=True)
return None
def _check_moderation_status(user_id: str) -> dict:
"""
Проверяет текущий статус модерации пользователя.
Возвращает {'status': 'ok', 'moderation_status': 'pending'|'published'|'none', ...}
"""
# Извлекаем логин из 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=30,
verify=False
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Ошибка проверки статуса модерации: {e}")
return {"status": "error", "reason": f"Ошибка проверки статуса: {str(e)}"}
def _upload_to_api(image_bytes: bytes, user_id: str, coords: tuple) -> dict:
"""
Отправляет фото и координаты в Django API.
Возвращает JSON от сервера.
"""
x1, y1, x2, y2 = coords
try:
response = requests.post(
API_URL,
headers={"X-API-Key": API_KEY},
data={
"user_id": user_id,
"crop_x1": x1,
"crop_y1": y1,
"crop_x2": x2,
"crop_y2": y2,
},
files={
"image": ("photo.jpg", image_bytes, "image/jpeg")
},
timeout=300,
verify=False
)
response.raise_for_status()
# Логгируем ответ для отладки
logger.info(f"API response status: {response.status_code}, body[:500]: {response.text[:500]}")
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"Ошибка API: {e}")
return {"status": "error", "reason": f"Ошибка сети: {str(e)}"}
except Exception as e:
logger.error(f"Ошибка отправки в API: {e}")
return {"status": "error", "reason": f"Ошибка: {str(e)}"}
def prepare_ad_photo(input_path: str, user_id: str, output_path: str = None, target_size: int = 96) -> dict:
"""
Отправляет фото на сервер через API.
output_path и target_size больше не используются (оставлены для обратной совместимости).
"""
try:
# Проверяем статус модерации перед отправкой
status = _check_moderation_status(user_id)
if status.get("status") == "ok" and status.get("moderation_status") == "pending":
photo_id = status.get("photo_id", "???")
return {
"success": False,
"error": f"У вас уже есть фото на модерации (ID: {photo_id}). Дождитесь решения модератора."
}
# 1. Читаем байты
if isinstance(input_path, bytes):
image_bytes = input_path
else:
with open(input_path, 'rb') as f:
image_bytes = f.read()
# 2. Ищем лицо и получаем координаты
coords = get_crop_coordinates(image_bytes)
if not coords:
return {"success": False, "error": "Лицо не найдено! Пожалуйста, сделайте фото анфас при хорошем освещении."}
# 3. Отправляем в API
api_result = _upload_to_api(image_bytes, user_id, coords)
# 4. Возвращаем результат
if api_result.get("status") == "ok":
return {
"success": True,
"photo_id": api_result.get("photo_id"),
"verdict": api_result.get("verdict"),
"message": api_result.get("message"),
}
else:
return {
"success": False,
"error": api_result.get("reason", "Неизвестная ошибка API")
}
except Exception as e:
logger.error(f"Ошибка в prepare_ad_photo: {e}", exc_info=True)
return {"success": False, "error": str(e)}
def make_face_crop_bytes(image_bytes: bytes, coords: tuple) -> bytes:
"""
Вырезает квадратный кадр по центру лица (тот же регион, что превью
бота и публикуемый на сайте кроп) и возвращает JPEG-байты.
На проверку уходит именно этот кроп: правило «голова ≥ 30%» и все
дальнейшие проверки имеют смысл для публикуемого результата, а не
для произвольно снятого оригинала.
Returns:
JPEG-байты квадрата (полное разрешение, без ресайза).
"""
from PIL import Image
import io
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))
buf = io.BytesIO()
cropped.save(buf, format="JPEG", quality=90)
return buf.getvalue()
def check_photo_api(image_bytes: bytes, timeout: float = 300.0) -> dict:
"""
Dry-run проверка фото через API сайта (POST /api/photo-bot/check/).
image_bytes — АВТО-КРОП ЛИЦА от бота (make_face_crop_bytes), тот же
кадр, что и превью/публикация. Фото проходит полный пайплайн проверки
на сайте (MediaPipe CV + ARKit-формы + VLM ИИ), НО ни в БД, ни в AD,
ни в Orion не попадает — ответ возвращается боту для тест-под-режима.
Returns:
{"status": "ok", "verdict", "ai_reason", "cv_approved", "cv_reason",
"raw_answer", "forced_moderation", "timings", "ai_metrics"}
или {"status": "error", "reason"}
"""
url = API_URL.replace("/upload/", "/check/")
try:
response = requests.post(
url,
headers={"X-API-Key": API_KEY},
files={"image": ("photo.jpg", image_bytes, "image/jpeg")},
timeout=timeout,
verify=False
)
response.raise_for_status()
logger.info(f"API /check/ response: status={response.status_code}, body[:500]: {response.text[:500]}")
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"Ошибка /check/ API: {e}")
return {"status": "error", "reason": f"Ошибка сети при обращении к сайту: {e}"}
except Exception as e:
logger.error(f"Ошибка /check/: {e}")
return {"status": "error", "reason": f"Ошибка сайта проверки: {e}"}
Binary file not shown.
Binary file not shown.
+264 -39
View File
@@ -198,16 +198,31 @@ def sd_file_upload_error() -> str:
# =========================================================
# PHOTO BOT
# =========================================================
# Модуль для обновления фото профиля
# Модуль для обновления фото профиля — интеграция с Django API
# --- Private functions (Internal logic) ---
def _photo_welcome(emojis: dict) -> str:
return (
f"🖼 <b>Режим обновления фото профиля</b>\n\n"
f"📸 Пришлите мне вашу фотографию.\n\n"
f"{emojis['0']}В главное меню"
def _photo_welcome(emojis: dict, show_test: bool = False, info: str = None, pending: bool = False) -> str:
"""Входное сообщение фото-режима. info — необязательный блок о статусе
модерации (отказ). pending=True — фото уже на модерации: без призыва
отправить фото, только статус."""
text = f"🖼 <b>Режим обновления фото профиля</b>\n\n"
if pending:
text += (
f"⏳ <b>У вас уже есть фото на модерации.</b>\n"
f"Ожидайте решения модератора.\n\n"
)
else:
text += (
f"📸 Пришлите мне вашу фотографию.\n\n"
f"Бот автоматически найдёт лицо, покажет превью\n"
f"и отправит фото на публикацию в ваши корпоративные сервисы.\n\n"
)
if info:
text += info + "\n\n"
if show_test:
text += f"{emojis['1']} — тест проверка фото\n"
return text + f"{emojis['0']}В главное меню"
def _photo_unknown(emojis: dict) -> str:
return (
@@ -217,11 +232,203 @@ def _photo_unknown(emojis: dict) -> str:
f"{emojis['0']}В главное меню"
)
def _photo_uploading() -> str:
return "Отправка..."
def _photo_processing_face(emojis: dict) -> str:
return "<i>Ищу лицо на фото, пожалуйста, подождите...</i>"
def _photo_success_sent() -> str:
return "📩 <b>Фото отправлено на утверждение.</b>"
def _photo_preview_ready(emojis: dict) -> str:
return (
f"📸 Вот ваш кадр.\n\n"
f"{emojis['1']} — Подтвердить\n"
f"{emojis['2']} — Вырезать заново\n\n"
f"{emojis['0']}В главное меню"
)
def _photo_ai_processing(emojis: dict) -> str:
return (
"⏳ <i>ИИ обрабатывает фото. Это может занять до 5 минут.\n"
"Пожалуйста, не закрывайте чат и подождите...</i>"
)
def _photo_status_info(status: dict = None, emojis: dict = None) -> str | None:
"""Блок о статусе модерации для входного сообщения. Универсальный для всех
случаев: rejected → причина отказа, pending → ожидание, прочее → пусто."""
if not status or status.get("status") != "ok":
return None
ms = status.get("moderation_status")
if ms == "rejected":
reason = (status.get("rejection_reason") or "Причина не указана.").strip()
return (
f"⚠️ <b>Ваше предыдущее фото было отклонено.</b>\n"
f"📝 Причина: {reason}\n\n"
f"Пожалуйста, отправьте новое фото."
)
if ms == "pending":
return (
f"⏳ <b>У вас уже есть фото на модерации.</b>\n"
f"Ожидайте решения модератора."
)
return None
def _photo_test_welcome(emojis: dict) -> str:
"""Тест-приветствие: фото уйдёт напрямую в ИИ, никуда не публикуется."""
return (
f"🧪 <b>Тестовый режим фото-проверки</b>\n\n"
f"📸 Пришлите фотографию — она уйдёт <b>напрямую в ИИ</b>.\n"
f"Бот покажет вердикт ИИ. <b>Фото никуда не публикуется</b>.\n\n"
f"{emojis['9']} — Назад\n"
f"{emojis['0']}В главное меню"
)
def _photo_test_preview(emojis: dict) -> str:
return (
f"🧪 <b>Тестовый режим</b>\n\n"
f"Кадр получен. Фото никуда не публикуется.\n\n"
f"{emojis['1']} — отправлю фото на проверку по сайту\n"
f"{emojis['2']} — Загрузить другое фото\n"
f"{emojis['9']} — Назад\n"
f"{emojis['0']}В главное меню"
)
def _photo_test_ai_checking(emojis: dict) -> str:
return (
"🧪 <i>Отправляю фото на проверку по сайту (тестовый режим)...\n"
"Фото проходят полный контроль: CV, гримасы, ИИ.\n"
"Обычно это занимает 1-3 минуты. Пожалуйста, подождите...</i>"
)
def _photo_test_receiving(emojis: dict) -> str:
return "🧪 <i>Ищу лицо на фото (тестовый режим), пожалуйста, подождите...</i>"
def _photo_test_result(verdict: str, reason: str, cv_approved=None, cv_reason: str = '',
forced=None, timings: dict = None, ai_metrics: dict = None,
frame: dict = None, emojis: dict = None) -> str:
emojis = emojis or {}
icons = {"Прошел": "", "Не прошел": "", "На модерацию": ""}
icon = icons.get(verdict, "")
parts = [
"🧪 <b>Тестовый режим: результат проверки</b>\n",
f"{icon} <b>Вердикт:</b> {verdict}\n",
]
if reason:
parts.append(f"📝 <b>Причина (ИИ):</b> {reason}\n")
# Детерминированный слой (MediaPipe CV + ARKit-формы)
if cv_approved is True:
line = "🎯 <b>CV-проверка:</b> ✅ пройдена"
if cv_reason:
line += f" — <i>{cv_reason}</i>"
parts.append(line + "\n")
elif cv_approved is False:
parts.append("🎯 <b>CV-проверка:</b> ❌ отклонена\n")
if cv_reason:
parts.append(f"↳ <i>{cv_reason}</i>\n")
else:
parts.append("🎯 <b>CV-проверка:</b> —\n")
# Метрики кадра, которые реально измерил сайт (виден именно проверенный кадр)
if frame:
bb = frame.get('face_bbox_px') or {}
try:
box = f"[{bb.get('x1')}..{bb.get('x2')}]×[{bb.get('y1')}..{bb.get('y2')}]"
parts.append(
f"📐 <b>Кадр (измерено сайтом):</b> {frame.get('img_w')}×{frame.get('img_h')} px, "
f"бокс лица {box}, голова <b>{frame.get('head_pct')}%</b> "
f"(норма ≥ {int(frame.get('min_head_percent', 30))}%)\n"
)
except Exception:
pass
if forced:
parts.append("️ <i>На сайте включён режим принудительной модерации: в реальном режиме фото ушло бы модератору, а не было опубликовано сразу.</i>\n")
# Тайминги этапов
if timings:
names = [
('cv', "CV (геометрия)"),
('expression', "Гримасы (ARKit-формы)"),
('ai', "ИИ (llama.cpp)"),
('total', "Всего"),
]
tl = ["📊 <b>Технические показатели (по этапам)</b>"]
for k, name in names:
v = timings.get(k)
if v is not None:
tl.append(f"{name}: <b>{float(v):.2f} сек</b>")
if len(tl) > 1:
parts.append("\n".join(tl) + "\n")
# Токены и скорость (сайт передаёт их из ответа llama.cpp)
if ai_metrics:
ml = ["🔢 <b>ИИ: токены и скорость</b>"]
pt = ai_metrics.get('prompt_tokens')
gps = ai_metrics.get('prompt_per_second')
if pt is not None:
s = f"⬇️ Приём (промпт): <b>{pt} токенов</b>"
if gps is not None:
s += f"{float(gps):.1f} ток/сек"
ml.append(s)
ct = ai_metrics.get('completion_tokens')
ggs = ai_metrics.get('predicted_per_second')
if ct is not None:
s = f"⬆️ Отдача (генерация): <b>{ct} токенов</b>"
if ggs is not None:
s += f"{float(ggs):.1f} ток/сек"
ml.append(s)
if len(ml) > 1:
parts.append("\n".join(ml) + "\n")
parts.append("⚠️ <i>Тестовый режим: фото не публиковалось и никуда не отправлялось.</i>\n\n")
parts.append(f"{emojis.get('2', '2')} — Загрузить другое фото\n")
parts.append(f"{emojis.get('9', '9')} — Назад\n")
parts.append(f"{emojis.get('0', '0')}В главное меню")
return "\n".join(parts)
def _photo_test_ai_error(emojis: dict, error: str = '', elapsed: float = None) -> str:
detail = ''
err = (error or '').strip()
if len(err) > 400:
err = err[:400] + ''
if err:
detail += f"📋 <b>Детали:</b> <i>{err}</i>\n"
if elapsed is not None:
detail += f"⏱ Время до ошибки: <b>{elapsed:.1f} сек</b>\n"
return (
f"❌ <b>Ошибка проверки фото.</b>\n\n"
f"{detail}\n"
f"Сервис проверки временно недоступен. Повторите попытку позже.\n\n"
f"{emojis['1']} — Повторить проверку\n"
f"{emojis['2']} — Загрузить другое фото\n"
f"{emojis['0']}В главное меню"
)
def _photo_success_published(emojis: dict) -> str:
return (
f"🎉 <b>Фото успешно опубликовано!</b>\n\n"
f"Применение во всех системах может занять до 2-х суток.\n\n"
f"{emojis['0']}В главное меню"
)
def _photo_success_pending(emojis: dict) -> str:
return (
f"📋 <b>Фото отправлено на модерацию.</b>\n\n"
f"Ожидайте уведомления.\n\n"
f"{emojis['0']}В главное меню"
)
def _photo_error_rejected(reason: str, emojis: dict) -> str:
return (
f"⚠️ <b>Фото отклонено:</b> {reason}\n\n"
f"Пожалуйста, отправьте другое фото.\n\n"
f"{emojis['0']}В главное меню"
)
def _photo_error_already_pending(emojis: dict) -> str:
return (
f"⚠️ <b>У вас уже есть фото на модерации.</b>\n\n"
f"Подождите завершения текущей обработки.\n\n"
f"{emojis['0']}В главное меню"
)
def _photo_cancel(emojis: dict) -> str:
return (
@@ -229,47 +436,65 @@ def _photo_cancel(emojis: dict) -> str:
f"{emojis['0']}В главное меню"
)
def _photo_processing() -> str:
return "⏳ Обработка..."
def _photo_preview_ready(emojis: dict) -> str:
return (
f"... <b>Фото готово!</b>\n\n"
f"{emojis['1']} — Отправить на утверждение\n"
f"{emojis['2']} — Попробовать другое\n\n"
f"{emojis['0']}В главное меню"
)
def _photo_error_processing(error_text: str, emojis: dict) -> str:
return (
f"❌ <b>Ошибка:</b> {error_text}\n\n"
f"Пожалуйста, отправьте другое фото.\n\n"
f"{emojis['0']}В главное меню"
)
# --- Public constants and functions ---
PHOTO_MAIN_MENU_TEXT = _photo_welcome(EMOJI_DIGITS)
PHOTO_TEST_ENTRY_TEXT = _photo_welcome(EMOJI_DIGITS, show_test=True)
PHOTO_UNKNOWN_CMD_TEXT = _photo_unknown(EMOJI_DIGITS)
def photo_error_handling() -> str:
return system_error_text(EMOJI_DIGITS, show_back=False)
PHOTO_UPLOADING_TEXT = _photo_uploading()
def photo_success_sent() -> str:
return _photo_success_sent()
def photo_cancel_text() -> str:
return _photo_cancel(EMOJI_DIGITS)
PHOTO_PROCESSING_TEXT = _photo_processing()
PHOTO_PROCESSING_FACE_TEXT = _photo_processing_face(EMOJI_DIGITS)
def photo_preview_ready() -> str:
return _photo_preview_ready(EMOJI_DIGITS)
def photo_error_processing(error_text: str) -> str:
return _photo_error_processing(error_text, EMOJI_DIGITS)
PHOTO_AI_PROCESSING_TEXT = _photo_ai_processing(EMOJI_DIGITS)
def photo_success_published() -> str:
return _photo_success_published(EMOJI_DIGITS)
def photo_success_pending() -> str:
return _photo_success_pending(EMOJI_DIGITS)
def photo_error_rejected(reason: str) -> str:
return _photo_error_rejected(reason, EMOJI_DIGITS)
def photo_error_already_pending() -> str:
return _photo_error_already_pending(EMOJI_DIGITS)
def photo_cancel_text() -> str:
return _photo_cancel(EMOJI_DIGITS)
def photo_welcome_with_status(status: dict = None, show_test: bool = False) -> str:
"""Универсальное входное сообщение фото-режима: меню + статус модерации.
pending — только статус (без призыва отправить фото);
show_test — добавить кнопку тест-проверки (PHOTO_TEST_MODE)."""
info = _photo_status_info(status, EMOJI_DIGITS)
pending = bool(
status and status.get("status") == "ok"
and status.get("moderation_status") == "pending"
)
return _photo_welcome(EMOJI_DIGITS, show_test=show_test, info=info, pending=pending)
PHOTO_TEST_WELCOME_TEXT = _photo_test_welcome(EMOJI_DIGITS)
def photo_test_welcome() -> str:
"""Тест-приветствие (тест-под-режим фото-проверки)."""
return _photo_test_welcome(EMOJI_DIGITS)
PHOTO_TEST_PREVIEW_TEXT = _photo_test_preview(EMOJI_DIGITS)
PHOTO_TEST_AI_CHECKING_TEXT = _photo_test_ai_checking(EMOJI_DIGITS)
PHOTO_TEST_RECEIVING_TEXT = _photo_test_receiving(EMOJI_DIGITS)
def photo_test_result(verdict: str, ai_reason: str = '', cv_approved=None, cv_reason: str = '',
forced=None, timings: dict = None, ai_metrics: dict = None,
frame: dict = None) -> str:
return _photo_test_result(verdict, ai_reason, cv_approved, cv_reason,
forced, timings, ai_metrics, frame, EMOJI_DIGITS)
def photo_test_ai_error(error: str = '') -> str:
return _photo_test_ai_error(EMOJI_DIGITS, error)
# =========================================================