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_INSTRUCT = True # Модуль инструкций (Trueconf, Почта)
ENABLE_LK = True # Личный кабинет сотрудника (отпуска, расчетные листки, справки) ENABLE_LK = True # Личный кабинет сотрудника (отпуска, расчетные листки, справки)
# =========================================================
# 1.1 ТЕСТОВЫЙ ПОД-РЕЖИМ ФОТО-БОТА («1 — тест проверка фото»)
# При включённом флаге в меню «Режим обновления фото профиля»
# появляется пункт «1 — тест проверка фото». Внутри под-режима
# фото уходит НАПРЯМУЮ в ИИ (llama.cpp, тот же endpoint и промпт,
# что в SibCemProfile/PhotoApp/photo_validator.py), результат
# показывается пользователю. Само фото никуда НЕ ДОСЫЛАЕТСЯ
# (ни в AD, ни в API, ни на модерацию). Штатный поток
# (фото → превью → публикация) работает параллельно.
# =========================================================
PHOTO_TEST_MODE = False # True — в меню фото-режима доступен пункт «тест проверка фото»
# ========================================================= # =========================================================
# 2. КОНТРОЛЬ ДОСТУПА # 2. КОНТРОЛЬ ДОСТУПА
# ========================================================= # =========================================================
+6 -3
View File
@@ -110,6 +110,10 @@ async def main_menu(msg: Message):
menu_shown = USER_MENU_SHOWN.get(user_id, False) menu_shown = USER_MENU_SHOWN.get(user_id, False)
if msg.text: if msg.text:
cmd = msg.text.strip().lower() cmd = msg.text.strip().lower()
# Обработка "0" — возврат в главное меню
if cmd in ("0", "меню"):
await msg.answer(MENU_TEXT, parse_mode="html")
return
action = MENU_MAP.get(cmd) action = MENU_MAP.get(cmd)
if action == "SD": if action == "SD":
USER_MENU_SHOWN[user_id] = True USER_MENU_SHOWN[user_id] = True
@@ -119,9 +123,8 @@ async def main_menu(msg: Message):
return return
elif action == "PHOTO": elif action == "PHOTO":
USER_MENU_SHOWN[user_id] = True USER_MENU_SHOWN[user_id] = True
set_state(user_id, "PHOTO_MODE") from photo_bot.handlers import send_dynamic_photo_main_menu
log_menu_stats(user_id, "Photo Bot", "Вход") await send_dynamic_photo_main_menu(msg, user_id)
await msg.answer(PHOTO_MAIN_MENU_TEXT, parse_mode="html")
return return
elif action == "TRANSCRIPTION": elif action == "TRANSCRIPTION":
USER_MENU_SHOWN[user_id] = True 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 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__))))
@@ -10,8 +11,21 @@ from trueconf import Bot, Dispatcher, Router, Message
from trueconf.types import FSInputFile from trueconf.types import FSInputFile
# Наши обработчики # Наши обработчики
from photo_processor import prepare_ad_photo from photo_processor import prepare_ad_photo, get_crop_coordinates
from ad_helper import upload_photo_to_ad 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 * from config.config import *
@@ -31,9 +45,38 @@ router = Router()
tc_bot = None tc_bot = None
# 🧠 ПАМЯТЬ БОТА: Храним пути к фото, ожидающим подтверждения # 🧠 ПАМЯТЬ БОТА: Храним пути к фото, ожидающим подтверждения
# Пример: {'ds.krivochenko': '/tmp/ready_ds.krivochenko.jpg'} # Пример: {'ds.krivochenko': {'path': '/tmp/orig_abc.jpg', 'coords': (100, 50, 300, 400)}}
pending_photos = {} 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() @router.message()
async def handle_message(msg: Message): async def handle_message(msg: Message):
global tc_bot global tc_bot
@@ -52,39 +95,67 @@ async def handle_message(msg: Message):
# Если согласен # Если согласен
if answer in ["да", "yes", "+", "загрузить", "давай", "конечно"]: if answer in ["да", "yes", "+", "загрузить", "давай", "конечно"]:
photo_path = pending_photos[user_login] photo_data = pending_photos[user_login]
await msg.answer("⏳ Подключаюсь к Active Directory и загружаю фото...") original_path = photo_data['path']
coords = photo_data['coords']
# Запускаем скрипт загрузки в AD await msg.answer(PHOTO_UPLOADING_API_TEXT)
upload_result = await asyncio.to_thread(upload_photo_to_ad, user_login, photo_path)
if upload_result["success"]: try:
await msg.answer( # Отправляем фото через API
"🎉 **Ура! Фото успешно установлено в Active Directory!**\n\n" result = await asyncio.to_thread(
"Оно появится в Outlook, TrueConf и других системах в течение нескольких часов (после синхронизации серверов)." prepare_ad_photo, original_path, user_login
) )
else:
await msg.answer(f"❌ **Ошибка при загрузке:** {upload_result.get('error')}")
# Убираем за собой файл и стираем пользователя из памяти if result["success"]:
if os.path.exists(photo_path): verdict = result.get("verdict")
os.remove(photo_path) 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] del pending_photos[user_login]
return return
# Если отказался # Если отказался
elif answer in ["нет", "no", "-", "отмена"]: elif answer in ["нет", "no", "-", "отмена"]:
photo_path = pending_photos[user_login] photo_data = pending_photos[user_login]
await msg.answer("🚫 Загрузка отменена. Можете прислать мне другую фотографию!") original_path = photo_data['path']
if os.path.exists(photo_path): await msg.answer(photo_cancel_text())
os.remove(photo_path)
if os.path.exists(original_path):
os.remove(original_path)
del pending_photos[user_login] del pending_photos[user_login]
return return
# Если написал что-то непонятное, но мы ждем ответа # Если написал что-то непонятное
else: else:
await msg.answer("❓ Пожалуйста, напишите **Да** (чтобы загрузить фото в AD) или **Нет** (чтобы отменить). Либо просто пришлите новую фотографию.") await msg.answer(
f"❓ Пожалуйста, нажмите:\n"
f"1️⃣ — Подтвердить\n"
f"2️⃣ — Вырезать заново\n\n"
f"Или просто отправьте новое фото."
)
return return
# ===================================================================== # =====================================================================
@@ -97,64 +168,78 @@ async def handle_message(msg: Message):
await msg.answer("❌ Не удалось получить файл. Попробуйте еще раз.") await msg.answer("❌ Не удалось получить файл. Попробуйте еще раз.")
return return
ready_path = f"/tmp/ready_{user_login}.jpg" await msg.answer(PHOTO_PROCESSING_FACE_TEXT)
raw_path = None
await msg.answer("⏳ Обрабатываю ваше фото, пожалуйста, подождите...")
try: try:
downloaded_file = await tc_bot.download_file_by_id(file_id) downloaded_file = await tc_bot.download_file_by_id(file_id)
raw_path = downloaded_file 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( await msg.answer_photo(
photo_document, preview_doc, preview_doc,
photo_document, caption=photo_preview_ready()
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 pending_photos[user_login] = {
logger.info(f"Фото подготовлено для {user_login}, ждем подтверждения.") 'path': original_path,
'coords': coords,
'preview_path': preview_path
}
logger.info(f"Фото подготовлено для {user_login}, координаты: {coords}")
else: else:
await msg.answer(f"❌ Ошибка обработки: {result.get('error')}") await msg.answer("❌ Ошибка обработки фото. Попробуйте снова.")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при работе с фото для {user_login}: {e}") logger.error(f"Ошибка при работе с фото для {user_login}: {e}")
await msg.answer("❌ Произошла системная ошибка при обработке.") await msg.answer(photo_error_handling())
finally: finally:
# ВАЖНО: Удаляем ТОЛЬКО оригинал от TrueConf (raw_path). # Удаляем оригинал TrueConf, но не превью (оно в pending_photos)
# Готовое фото (ready_path) оставляем на диске, оно ждет ответа "Да"! if 'raw_path' in locals() and os.path.exists(raw_path):
if raw_path and os.path.exists(raw_path): try:
try: os.remove(raw_path) os.remove(raw_path)
except: pass except:
pass
return return
# ===================================================================== # =====================================================================
# СЦЕНАРИЙ 3: Дефолтное приветствие (если просто написали текст) # СЦЕНАРИЙ 3: Дефолтное приветствие (если просто написали текст)
# ===================================================================== # =====================================================================
await msg.answer( await msg.answer(PHOTO_MAIN_MENU_TEXT)
"👋 Привет! Я корпоративный фотограф для Active Directory.\n\n"
"📸 **Просто отправь мне фото**, и я автоматически:\n"
"1. Найду лицо на снимке и сделаю правильный кадр.\n"
"2. Улучшу резкость и сожму до стандарта 240x240 px.\n"
"3. Предложу загрузить результат прямо в твой профиль!"
)
async def main(): async def main():
global tc_bot global tc_bot
+403 -65
View File
@@ -2,7 +2,6 @@
import os import os
import asyncio import asyncio
import logging import logging
import httpx
import smtplib import smtplib
import time import time
from datetime import datetime from datetime import datetime
@@ -15,18 +14,27 @@ from trueconf.types import FSInputFile
import config.config as config import config.config as config
from utils import states from utils import states
from utils.menu import MENU_TEXT 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 ( from utils.texts import (
UNKNOWN_MAIN_CMD_TEXT,
EMOJI_DIGITS, EMOJI_DIGITS,
PHOTO_MAIN_MENU_TEXT, PHOTO_MAIN_MENU_TEXT,
system_error_text,
photo_error_handling, photo_error_handling,
PHOTO_UPLOADING_TEXT, PHOTO_PROCESSING_FACE_TEXT,
photo_success_sent, PHOTO_AI_PROCESSING_TEXT,
photo_success_published,
photo_success_pending,
photo_error_rejected,
photo_error_already_pending,
photo_cancel_text, photo_cancel_text,
PHOTO_PROCESSING_TEXT,
photo_preview_ready, 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 # 🔌 Импортируем централизованную функцию сбора статистики из main
@@ -36,8 +44,23 @@ logger = logging.getLogger(__name__)
router = Router() router = Router()
# Словарь для временного хранения путей к фото пользователей # Словарь для временного хранения путей к фото пользователей
# Формат: {user_id: {'path': '/tmp/orig_user.jpg', 'coords': (x1,y1,x2,y2)}}
pending_photos = {} 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): 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 asyncio.to_thread(log_and_notify_photo_error, user_id, action, error_details)
await msg.answer(photo_error_handling(), parse_mode="html") 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 cmd = raw_num
break 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 возвращают в общее меню с очисткой кэша) --- # --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню с очисткой кэша) ---
if cmd in ["0", "9", "/start", "меню"]: if cmd in ["0", "9", "/start", "меню"]:
log_menu_stats(user_id, "Photo Bot", "Выход в главное меню") log_menu_stats(user_id, "Photo Bot", "Выход в главное меню")
states.clear_state(user_id) states.clear_state(user_id)
test_submode_users.discard(user_id)
moderation_pending_users.discard(user_id)
if user_id in pending_photos: if user_id in pending_photos:
if os.path.exists(pending_photos[user_id]): photo_data = pending_photos[user_id]
os.remove(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] del pending_photos[user_id]
await msg.answer(MENU_TEXT, parse_mode="html") await msg.answer(MENU_TEXT, parse_mode="html")
return return
# --- ПОДТВЕРЖДЕНИЕ ОТПРАВКИ (Кнопка 1 или 2) --- # --- СОСТОЯНИЕ «ФОТО НА МОДЕРАЦИИ»: активного фото нет ---
if not is_attachment and user_id in pending_photos: if user_id in moderation_pending_users and user_id not in pending_photos:
if cmd == "1": if not is_attachment:
photo_path = pending_photos[user_id] status = await check_moderation_status(user_id)
final_filename = f"{user_login}.bmp" 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: try:
async with httpx.AsyncClient(timeout=10.0) as client: with open(original_path, 'rb') as f:
with open(photo_path, "rb") as f: raw_photo = f.read()
files = {'file': (final_filename, f, 'image/bmp')} # На проверку уходит АВТО-КРОП ЛИЦА (тот же, что в превью
response = await client.post(config.TRASH_API_URL, files=files) # и что публикуется на сайте) — правила «голова ≥ 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: if check_res.get("status") != "ok":
# ФИКСАЦИЯ СТАТИСТИКИ: Успешная выгрузка фото на утверждение # Фото остаётся в pending — можно повторить проверку (кнопка 1)
log_menu_stats(user_id, "Photo Bot", "Отправка фото на утверждение") log_menu_stats(user_id, "Photo Bot (TEST)", f"Ошибка проверки: {check_res.get('reason')}")
await msg.answer(photo_success_sent(), parse_mode="html") 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) states.clear_state(user_id)
if os.path.exists(photo_path): if user_id in pending_photos:
os.remove(photo_path)
del pending_photos[user_id] 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 return
# --- ОТМЕНА ФОТО (Кнопка 2) --- # --- ОТМЕНА ФОТО (Кнопка 2) ---
elif cmd == "2": elif cmd == "2":
log_menu_stats(user_id, "Photo Bot", "Отмена загруженного фото") log_menu_stats(user_id, "Photo Bot", "Отмена загруженного фото")
photo_path = pending_photos[user_id] photo_data = pending_photos[user_id]
if os.path.exists(photo_path): original_path = photo_data['path']
os.remove(photo_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] del pending_photos[user_id]
states.clear_state(user_id)
await msg.answer(photo_cancel_text(), parse_mode="html") await msg.answer(photo_cancel_text(), parse_mode="html")
return return
# --- ИСПРАВЛЕНО: Защита от мусорного ввода при висящем превью --- # --- ИСПРАВЛЕНО: Защита от мусорного ввода при висящем превью ---
else: else:
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html") await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return 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: if is_attachment:
file_id = getattr(msg.content, "file_id", None) file_id = getattr(msg.content, "file_id", None)
ready_path = f"/tmp/{user_login}.bmp" raw_bytes = None
raw_path = None
await msg.answer(PHOTO_PROCESSING_TEXT, parse_mode="html") await msg.answer(PHOTO_PROCESSING_FACE_TEXT, parse_mode="html")
try: try:
downloaded_file = await states.tc_bot.download_file_by_id(file_id) downloaded_file = await states.tc_bot.download_file_by_id(file_id)
raw_path = downloaded_file logger.info(f"Скачан файл для {user_login}: type={type(downloaded_file)}, len={len(downloaded_file) if isinstance(downloaded_file, (bytes, str)) else 'N/A'}")
result = await asyncio.to_thread(prepare_ad_photo, raw_path, ready_path, 96)
if result["success"]: # Скачиваем в байты напрямую
# ФИКСАЦИЯ СТАТИСТИКИ: Факт успешной загрузки и обработки нового фото if isinstance(downloaded_file, bytes):
log_menu_stats(user_id, "Photo Bot", "Загрузка и конвертация нового фото") raw_bytes = downloaded_file
pending_photos[user_id] = ready_path elif isinstance(downloaded_file, str) and os.path.exists(downloaded_file):
preview_path = f"/tmp/prev_{user_login}.jpg" 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: try:
Image.open(ready_path).save(preview_path, format="JPEG") from PIL import Image
p_main = FSInputFile(preview_path, filename="preview.jpg") import io
p_thumb = FSInputFile(preview_path, filename="preview_thumb.jpg") img = Image.open(io.BytesIO(raw_bytes))
await msg.answer_photo(p_main, p_thumb) 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: except Exception as lib_err:
await asyncio.to_thread(log_and_notify_photo_error, user_id, "Генерация превью", str(lib_err)) await asyncio.to_thread(log_and_notify_photo_error, user_id, "Генерация превью", str(lib_err))
finally: raise Exception("Ошибка генерации превью")
if os.path.exists(preview_path):
os.remove(preview_path) # Сохраняем оригинал и координаты
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) 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( await msg.answer(
photo_preview_ready(), photo_preview_ready(),
parse_mode="html" parse_mode="html"
) )
else:
error_text = result.get('error', 'Не удалось обработать фото.')
await msg.answer(
photo_error_processing(error_text),
parse_mode="html"
)
except Exception as e: except Exception as e:
logger.error(f"🔥 Полная ошибка: {e}", exc_info=True)
await handle_photo_error(msg, user_id, "Скачивание и обработка файла", str(e)) await handle_photo_error(msg, user_id, "Скачивание и обработка файла", str(e))
finally:
if raw_path and os.path.exists(raw_path):
try:
os.remove(raw_path)
except Exception as cleanup_err:
await asyncio.to_thread(log_and_notify_photo_error, user_id, "Очистка временных файлов", str(cleanup_err))
return return
+217 -88
View File
@@ -1,84 +1,65 @@
import io import io
import cv2 import cv2
import os
import logging import logging
import numpy as np import numpy as np
from PIL import Image, ImageFilter, ImageEnhance import requests
from PIL import Image
logger = logging.getLogger(__name__) 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: try:
# 1. Загружаем изображение для OpenCV pil_img = Image.open(io.BytesIO(image_bytes))
if isinstance(input_path, bytes): if pil_img.mode != 'RGB':
img = cv2.imdecode(np.frombuffer(input_path, np.uint8), cv2.IMREAD_COLOR) pil_img = pil_img.convert('RGB')
else: img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
img = cv2.imread(input_path) 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: if img is None:
return {"success": False, "error": "Не удалось прочитать файл изображения."} logger.warning("Не удалось декодировать изображение")
if isinstance(input_path, bytes): return None
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": "Не удалось прочитать файл изображения."}
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 2. Загружаем каскад Хаара для поиска лица
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
face_cascade = cv2.CascadeClassifier(cascade_path) face_cascade = cv2.CascadeClassifier(cascade_path)
# 3. Ищем лица
# Оставляем оптимальное значение minNeighbors=8 для баланса
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=8, minSize=(50, 50)) faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=8, minSize=(50, 50))
if len(faces) == 0: if len(faces) == 0:
return {"success": False, "error": "Лицо не найдено! Пожалуйста, сделайте фото анфас при хорошем освещении."} logger.warning("Лицо не найдено на фото")
return None
if len(faces) > 1: if len(faces) > 1:
faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True) faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True)
x, y, w, h = faces[0] 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) margin = int(w * 0.3)
y1 = max(0, y - int(margin * 1.5)) y1 = max(0, y - int(margin * 1.5))
y2 = min(img.shape[0], y + h + margin) 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_x2 = crop_x1 + side
crop_y2 = crop_y1 + side crop_y2 = crop_y1 + side
# 5. Обработка через Pillow logger.info(f"Лицо найдено: ({x},{y},{w},{h}), кроп: ({crop_x1},{crop_y1},{crop_x2},{crop_y2})")
pil_img = Image.open(io.BytesIO(input_path)) return (crop_x1, crop_y1, crop_x2, crop_y2)
# ВАЖНО: Убираем альфа-канал (прозрачность), иначе 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)}
except Exception as e: 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)} 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 # PHOTO BOT
# ========================================================= # =========================================================
# Модуль для обновления фото профиля # Модуль для обновления фото профиля — интеграция с Django API
# --- Private functions (Internal logic) --- # --- Private functions (Internal logic) ---
def _photo_welcome(emojis: dict) -> str: def _photo_welcome(emojis: dict, show_test: bool = False, info: str = None, pending: bool = False) -> str:
return ( """Входное сообщение фото-режима. info — необязательный блок о статусе
f"🖼 <b>Режим обновления фото профиля</b>\n\n" модерации (отказ). pending=True — фото уже на модерации: без призыва
f"📸 Пришлите мне вашу фотографию.\n\n" отправить фото, только статус."""
f"{emojis['0']}В главное меню" 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: def _photo_unknown(emojis: dict) -> str:
return ( return (
@@ -217,11 +232,203 @@ def _photo_unknown(emojis: dict) -> str:
f"{emojis['0']}В главное меню" f"{emojis['0']}В главное меню"
) )
def _photo_uploading() -> str: def _photo_processing_face(emojis: dict) -> str:
return "Отправка..." return "<i>Ищу лицо на фото, пожалуйста, подождите...</i>"
def _photo_success_sent() -> str: def _photo_preview_ready(emojis: dict) -> str:
return "📩 <b>Фото отправлено на утверждение.</b>" 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: def _photo_cancel(emojis: dict) -> str:
return ( return (
@@ -229,47 +436,65 @@ def _photo_cancel(emojis: dict) -> str:
f"{emojis['0']}В главное меню" 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 --- # --- Public constants and functions ---
PHOTO_MAIN_MENU_TEXT = _photo_welcome(EMOJI_DIGITS) 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) PHOTO_UNKNOWN_CMD_TEXT = _photo_unknown(EMOJI_DIGITS)
def photo_error_handling() -> str: def photo_error_handling() -> str:
return system_error_text(EMOJI_DIGITS, show_back=False) return system_error_text(EMOJI_DIGITS, show_back=False)
PHOTO_UPLOADING_TEXT = _photo_uploading() PHOTO_PROCESSING_FACE_TEXT = _photo_processing_face(EMOJI_DIGITS)
def photo_success_sent() -> str:
return _photo_success_sent()
def photo_cancel_text() -> str:
return _photo_cancel(EMOJI_DIGITS)
PHOTO_PROCESSING_TEXT = _photo_processing()
def photo_preview_ready() -> str: def photo_preview_ready() -> str:
return _photo_preview_ready(EMOJI_DIGITS) return _photo_preview_ready(EMOJI_DIGITS)
def photo_error_processing(error_text: str) -> str: PHOTO_AI_PROCESSING_TEXT = _photo_ai_processing(EMOJI_DIGITS)
return _photo_error_processing(error_text, 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)
# ========================================================= # =========================================================