Compare commits
58 Commits
9a9b90b3a1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 612564389b | |||
| b5cd3df5dd | |||
| a0ade670fe | |||
| 2a4e2fc9a2 | |||
| f18c564ead | |||
| ab58203982 | |||
| 859ac42a77 | |||
| bd0c14c8f0 | |||
| 8a7634daed | |||
| 7fa515de58 | |||
| e08c861149 | |||
| 1bdfd163fe | |||
| 0b9a697099 | |||
| 1c2d018be4 | |||
| 0b2364d79f | |||
| c358d4cbbb | |||
| 1ea42a9197 | |||
| 633eb27450 | |||
| 9271c23780 | |||
| 0941a28da4 | |||
| d3b1ba6e8a | |||
| 9cb234dda3 | |||
| 6f4c979567 | |||
| 9f49dd3e96 | |||
| aee2fe8f25 | |||
| 9f35db6f09 | |||
| f90dd77bd8 | |||
| a98fcd0feb | |||
| b065a85114 | |||
| 795f037f1b | |||
| 7ca86466f7 | |||
| 966b8d4774 | |||
| 7aa2d92b8b | |||
| 4418bcb3a3 | |||
| 7c74c19de6 | |||
| 781f52a301 | |||
| 90777785f4 | |||
| 182194a57a | |||
| 3148a6c5ab | |||
| 20759ac600 | |||
| 5644e6dc74 | |||
| 0fd2136bc0 | |||
| d6c7d1258d | |||
| 1990b915b2 | |||
| 2be4711a3a | |||
| 0dba3e7bf6 | |||
| 746d9bc7e4 | |||
| c0ec03526e | |||
| 0cf46713d4 | |||
| 55d95d3042 | |||
| 7196fe603a | |||
| ff6216cdf3 | |||
| a38fc7d889 | |||
| 5bd26d706e | |||
| 5deb7ff689 | |||
| 707dcf3437 | |||
| c7ae88a3e0 | |||
| 7b129e0836 |
Binary file not shown.
Binary file not shown.
@@ -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. КОНТРОЛЬ ДОСТУПА
|
||||
# =========================================================
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.
Binary file not shown.
Binary file not shown.
+266
-181
@@ -1,181 +1,266 @@
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
# Добавляем путь к корню проекта
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from trueconf import Bot, Dispatcher, Router, Message
|
||||
from trueconf.types import FSInputFile
|
||||
|
||||
# Наши обработчики
|
||||
from photo_processor import prepare_ad_photo
|
||||
from ad_helper import upload_photo_to_ad
|
||||
|
||||
# Настройки
|
||||
from config.config import *
|
||||
|
||||
# --- НАСТРОЙКА ЛОГИРОВАНИЯ ---
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler("photo_bot_log.txt", encoding="utf-8"),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = Router()
|
||||
tc_bot = None
|
||||
|
||||
# 🧠 ПАМЯТЬ БОТА: Храним пути к фото, ожидающим подтверждения
|
||||
# Пример: {'ds.krivochenko': '/tmp/ready_ds.krivochenko.jpg'}
|
||||
pending_photos = {}
|
||||
|
||||
@router.message()
|
||||
async def handle_message(msg: Message):
|
||||
global tc_bot
|
||||
|
||||
# Определяем логин пользователя
|
||||
user_login = msg.from_user.id.split('@')[0] if '@' in msg.from_user.id else msg.from_user.id
|
||||
|
||||
# Проверяем, вложение ли это
|
||||
is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT"
|
||||
|
||||
# =====================================================================
|
||||
# СЦЕНАРИЙ 1: Бот ждет ответа, и пользователь написал текст (Да/Нет)
|
||||
# =====================================================================
|
||||
if not is_attachment and user_login in pending_photos and msg.text:
|
||||
answer = msg.text.strip().lower()
|
||||
|
||||
# Если согласен
|
||||
if answer in ["да", "yes", "+", "загрузить", "давай", "конечно"]:
|
||||
photo_path = pending_photos[user_login]
|
||||
await msg.answer("⏳ Подключаюсь к Active Directory и загружаю фото...")
|
||||
|
||||
# Запускаем скрипт загрузки в AD
|
||||
upload_result = await asyncio.to_thread(upload_photo_to_ad, user_login, photo_path)
|
||||
|
||||
if upload_result["success"]:
|
||||
await msg.answer(
|
||||
"🎉 **Ура! Фото успешно установлено в Active Directory!**\n\n"
|
||||
"Оно появится в Outlook, TrueConf и других системах в течение нескольких часов (после синхронизации серверов)."
|
||||
)
|
||||
else:
|
||||
await msg.answer(f"❌ **Ошибка при загрузке:** {upload_result.get('error')}")
|
||||
|
||||
# Убираем за собой файл и стираем пользователя из памяти
|
||||
if os.path.exists(photo_path):
|
||||
os.remove(photo_path)
|
||||
del pending_photos[user_login]
|
||||
return
|
||||
|
||||
# Если отказался
|
||||
elif answer in ["нет", "no", "-", "отмена"]:
|
||||
photo_path = pending_photos[user_login]
|
||||
await msg.answer("🚫 Загрузка отменена. Можете прислать мне другую фотографию!")
|
||||
|
||||
if os.path.exists(photo_path):
|
||||
os.remove(photo_path)
|
||||
del pending_photos[user_login]
|
||||
return
|
||||
|
||||
# Если написал что-то непонятное, но мы ждем ответа
|
||||
else:
|
||||
await msg.answer("❓ Пожалуйста, напишите **Да** (чтобы загрузить фото в AD) или **Нет** (чтобы отменить). Либо просто пришлите новую фотографию.")
|
||||
return
|
||||
|
||||
# =====================================================================
|
||||
# СЦЕНАРИЙ 2: Пользователь прислал новую фотографию
|
||||
# =====================================================================
|
||||
if is_attachment:
|
||||
file_id = getattr(msg.content, "file_id", None)
|
||||
|
||||
if not file_id:
|
||||
await msg.answer("❌ Не удалось получить файл. Попробуйте еще раз.")
|
||||
return
|
||||
|
||||
ready_path = f"/tmp/ready_{user_login}.jpg"
|
||||
raw_path = None
|
||||
|
||||
await msg.answer("⏳ Обрабатываю ваше фото, пожалуйста, подождите...")
|
||||
|
||||
try:
|
||||
downloaded_file = await tc_bot.download_file_by_id(file_id)
|
||||
raw_path = downloaded_file
|
||||
|
||||
# Обрабатываем фото (с новым разрешением 240x240)
|
||||
result = await asyncio.to_thread(prepare_ad_photo, raw_path, ready_path, 240)
|
||||
|
||||
if result["success"]:
|
||||
photo_document = FSInputFile(ready_path, filename=f"AD_{user_login}.jpg")
|
||||
|
||||
# Отправляем фото и СРАЗУ задаем вопрос
|
||||
await msg.answer_photo(
|
||||
photo_document,
|
||||
photo_document,
|
||||
caption=(
|
||||
f"✅ **Фото готово!**\n\n"
|
||||
f"📐 Размер: 240x240 px\n"
|
||||
f"⚖️ Вес: {result['size_kb']} KB\n\n"
|
||||
f"❓ **Вам нравится результат? Загружаем в Active Directory?**\n"
|
||||
f"Напишите мне в ответ **Да** или **Нет**."
|
||||
)
|
||||
)
|
||||
|
||||
# 🧠 ЗАПОМИНАЕМ, что этот пользователь теперь в режиме ожидания ответа
|
||||
pending_photos[user_login] = ready_path
|
||||
logger.info(f"Фото подготовлено для {user_login}, ждем подтверждения.")
|
||||
|
||||
else:
|
||||
await msg.answer(f"❌ Ошибка обработки: {result.get('error')}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с фото для {user_login}: {e}")
|
||||
await msg.answer("❌ Произошла системная ошибка при обработке.")
|
||||
|
||||
finally:
|
||||
# ВАЖНО: Удаляем ТОЛЬКО оригинал от TrueConf (raw_path).
|
||||
# Готовое фото (ready_path) оставляем на диске, оно ждет ответа "Да"!
|
||||
if raw_path and os.path.exists(raw_path):
|
||||
try: os.remove(raw_path)
|
||||
except: pass
|
||||
|
||||
return
|
||||
|
||||
# =====================================================================
|
||||
# СЦЕНАРИЙ 3: Дефолтное приветствие (если просто написали текст)
|
||||
# =====================================================================
|
||||
await msg.answer(
|
||||
"👋 Привет! Я корпоративный фотограф для Active Directory.\n\n"
|
||||
"📸 **Просто отправь мне фото**, и я автоматически:\n"
|
||||
"1. Найду лицо на снимке и сделаю правильный кадр.\n"
|
||||
"2. Улучшу резкость и сожму до стандарта 240x240 px.\n"
|
||||
"3. Предложу загрузить результат прямо в твой профиль!"
|
||||
)
|
||||
|
||||
async def main():
|
||||
global tc_bot
|
||||
|
||||
logger.info(f"Запуск фото-бота на сервере {TC_SERVER}...")
|
||||
|
||||
dp = Dispatcher()
|
||||
dp.include_router(router)
|
||||
|
||||
tc_bot = Bot.from_credentials(
|
||||
server=TC_SERVER,
|
||||
username=TC_LOGIN,
|
||||
password=TC_PASSWORD,
|
||||
dispatcher=dp,
|
||||
verify_ssl=False
|
||||
)
|
||||
|
||||
await tc_bot.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Бот остановлен пользователем.")
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import io
|
||||
|
||||
# Добавляем путь к корню проекта
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from trueconf import Bot, Dispatcher, Router, Message
|
||||
from trueconf.types import FSInputFile
|
||||
|
||||
# Наши обработчики
|
||||
from photo_processor import prepare_ad_photo, get_crop_coordinates
|
||||
from utils.texts import (
|
||||
PHOTO_MAIN_MENU_TEXT,
|
||||
PHOTO_UNKNOWN_CMD_TEXT,
|
||||
PHOTO_PROCESSING_FACE_TEXT,
|
||||
PHOTO_UPLOADING_API_TEXT,
|
||||
photo_preview_ready,
|
||||
photo_success_published,
|
||||
photo_success_pending,
|
||||
photo_error_rejected,
|
||||
photo_error_already_pending,
|
||||
photo_cancel_text,
|
||||
photo_error_handling,
|
||||
EMOJI_DIGITS,
|
||||
)
|
||||
|
||||
# Настройки
|
||||
from config.config import *
|
||||
|
||||
# --- НАСТРОЙКА ЛОГИРОВАНИЯ ---
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler("photo_bot_log.txt", encoding="utf-8"),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = Router()
|
||||
tc_bot = None
|
||||
|
||||
# 🧠 ПАМЯТЬ БОТА: Храним пути к фото, ожидающим подтверждения
|
||||
# Пример: {'ds.krivochenko': {'path': '/tmp/orig_abc.jpg', 'coords': (100, 50, 300, 400)}}
|
||||
pending_photos = {}
|
||||
|
||||
|
||||
def _generate_preview(image_bytes: bytes, coords: tuple, size: int = 400) -> bytes | None:
|
||||
"""Генерирует превью фото с кропом по координатам."""
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(image_bytes))
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
x1, y1, x2, y2 = coords
|
||||
cropped = img.crop((x1, y1, x2, y2))
|
||||
|
||||
# Ресайз до квадрата
|
||||
side = min(cropped.size[0], cropped.size[1])
|
||||
left = (cropped.size[0] - side) // 2
|
||||
top = (cropped.size[1] - side) // 2
|
||||
cropped = cropped.crop((left, top, left + side, top + side))
|
||||
|
||||
preview = cropped.resize((size, size), Image.Resampling.LANCZOS)
|
||||
|
||||
buf = io.BytesIO()
|
||||
preview.save(buf, format="JPEG", quality=85)
|
||||
buf.seek(0)
|
||||
return buf.getvalue()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка генерации превью: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@router.message()
|
||||
async def handle_message(msg: Message):
|
||||
global tc_bot
|
||||
|
||||
# Определяем логин пользователя
|
||||
user_login = msg.from_user.id.split('@')[0] if '@' in msg.from_user.id else msg.from_user.id
|
||||
|
||||
# Проверяем, вложение ли это
|
||||
is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT"
|
||||
|
||||
# =====================================================================
|
||||
# СЦЕНАРИЙ 1: Бот ждет ответа, и пользователь написал текст (Да/Нет)
|
||||
# =====================================================================
|
||||
if not is_attachment and user_login in pending_photos and msg.text:
|
||||
answer = msg.text.strip().lower()
|
||||
|
||||
# Если согласен
|
||||
if answer in ["да", "yes", "+", "загрузить", "давай", "конечно"]:
|
||||
photo_data = pending_photos[user_login]
|
||||
original_path = photo_data['path']
|
||||
coords = photo_data['coords']
|
||||
|
||||
await msg.answer(PHOTO_UPLOADING_API_TEXT)
|
||||
|
||||
try:
|
||||
# Отправляем фото через API
|
||||
result = await asyncio.to_thread(
|
||||
prepare_ad_photo, original_path, user_login
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
verdict = result.get("verdict")
|
||||
if verdict == "published":
|
||||
await msg.answer(photo_success_published())
|
||||
elif verdict == "pending_moderation":
|
||||
await msg.answer(photo_success_pending())
|
||||
else:
|
||||
# Успех, но вердикт другой — показываем сообщение
|
||||
msg_text = result.get("message", "Фото обработано")
|
||||
await msg.answer(f"✅ {msg_text}")
|
||||
else:
|
||||
error = result.get("error", "Неизвестная ошибка")
|
||||
if "уже есть фото" in error:
|
||||
await msg.answer(photo_error_already_pending())
|
||||
elif "отклонено" in error.lower() or "не прошел" in error.lower():
|
||||
await msg.answer(photo_error_rejected(error))
|
||||
else:
|
||||
await msg.answer(f"❌ Ошибка: {error}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки фото: {e}")
|
||||
await msg.answer(photo_error_handling())
|
||||
|
||||
# Убираем за собой
|
||||
if os.path.exists(original_path):
|
||||
os.remove(original_path)
|
||||
del pending_photos[user_login]
|
||||
return
|
||||
|
||||
# Если отказался
|
||||
elif answer in ["нет", "no", "-", "отмена"]:
|
||||
photo_data = pending_photos[user_login]
|
||||
original_path = photo_data['path']
|
||||
|
||||
await msg.answer(photo_cancel_text())
|
||||
|
||||
if os.path.exists(original_path):
|
||||
os.remove(original_path)
|
||||
del pending_photos[user_login]
|
||||
return
|
||||
|
||||
# Если написал что-то непонятное
|
||||
else:
|
||||
await msg.answer(
|
||||
f"❓ Пожалуйста, нажмите:\n"
|
||||
f"1️⃣ — Подтвердить\n"
|
||||
f"2️⃣ — Вырезать заново\n\n"
|
||||
f"Или просто отправьте новое фото."
|
||||
)
|
||||
return
|
||||
|
||||
# =====================================================================
|
||||
# СЦЕНАРИЙ 2: Пользователь прислал новую фотографию
|
||||
# =====================================================================
|
||||
if is_attachment:
|
||||
file_id = getattr(msg.content, "file_id", None)
|
||||
|
||||
if not file_id:
|
||||
await msg.answer("❌ Не удалось получить файл. Попробуйте еще раз.")
|
||||
return
|
||||
|
||||
await msg.answer(PHOTO_PROCESSING_FACE_TEXT)
|
||||
|
||||
try:
|
||||
downloaded_file = await tc_bot.download_file_by_id(file_id)
|
||||
raw_path = downloaded_file
|
||||
|
||||
# Читаем байты
|
||||
with open(raw_path, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
|
||||
# Ищем лицо и получаем координаты
|
||||
coords = await asyncio.to_thread(get_crop_coordinates, image_bytes)
|
||||
|
||||
if not coords:
|
||||
await msg.answer(
|
||||
"❌ Лицо не найдено. Пожалуйста, сделайте фото анфас при хорошем освещении.\n\n"
|
||||
f"{EMOJI_DIGITS['0']} — В главное меню"
|
||||
)
|
||||
if os.path.exists(raw_path):
|
||||
os.remove(raw_path)
|
||||
return
|
||||
|
||||
# Генерируем превью
|
||||
preview_bytes = _generate_preview(image_bytes, coords, size=400)
|
||||
|
||||
if preview_bytes:
|
||||
# Сохраняем оригинал и координаты
|
||||
preview_path = f"/tmp/preview_{user_login}.jpg"
|
||||
with open(preview_path, 'wb') as f:
|
||||
f.write(preview_bytes)
|
||||
|
||||
# Сохраняем оригинал
|
||||
original_path = f"/tmp/orig_{user_login}.jpg"
|
||||
with open(original_path, 'wb') as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
# Отправляем превью
|
||||
preview_doc = FSInputFile(preview_path, filename=f"preview_{user_login}.jpg")
|
||||
|
||||
await msg.answer_photo(
|
||||
preview_doc, preview_doc,
|
||||
caption=photo_preview_ready()
|
||||
)
|
||||
|
||||
# Сохраняем состояние
|
||||
pending_photos[user_login] = {
|
||||
'path': original_path,
|
||||
'coords': coords,
|
||||
'preview_path': preview_path
|
||||
}
|
||||
logger.info(f"Фото подготовлено для {user_login}, координаты: {coords}")
|
||||
else:
|
||||
await msg.answer("❌ Ошибка обработки фото. Попробуйте снова.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с фото для {user_login}: {e}")
|
||||
await msg.answer(photo_error_handling())
|
||||
|
||||
finally:
|
||||
# Удаляем оригинал TrueConf, но не превью (оно в pending_photos)
|
||||
if 'raw_path' in locals() and os.path.exists(raw_path):
|
||||
try:
|
||||
os.remove(raw_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
return
|
||||
|
||||
# =====================================================================
|
||||
# СЦЕНАРИЙ 3: Дефолтное приветствие (если просто написали текст)
|
||||
# =====================================================================
|
||||
await msg.answer(PHOTO_MAIN_MENU_TEXT)
|
||||
|
||||
async def main():
|
||||
global tc_bot
|
||||
|
||||
logger.info(f"Запуск фото-бота на сервере {TC_SERVER}...")
|
||||
|
||||
dp = Dispatcher()
|
||||
dp.include_router(router)
|
||||
|
||||
tc_bot = Bot.from_credentials(
|
||||
server=TC_SERVER,
|
||||
username=TC_LOGIN,
|
||||
password=TC_PASSWORD,
|
||||
dispatcher=dp,
|
||||
verify_ssl=False
|
||||
)
|
||||
|
||||
await tc_bot.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Бот остановлен пользователем.")
|
||||
|
||||
+410
-72
@@ -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)
|
||||
|
||||
if response.status_code == 200:
|
||||
# ФИКСАЦИЯ СТАТИСТИКИ: Успешная выгрузка фото на утверждение
|
||||
log_menu_stats(user_id, "Photo Bot", "Отправка фото на утверждение")
|
||||
await msg.answer(photo_success_sent(), parse_mode="html")
|
||||
|
||||
states.clear_state(user_id)
|
||||
if os.path.exists(photo_path):
|
||||
os.remove(photo_path)
|
||||
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)
|
||||
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:
|
||||
await handle_photo_error(msg, user_id, "Подключение к API загрузки", str(e))
|
||||
logger.error(f"🔥 [TEST MODE] Ошибка проверки фото: {e}")
|
||||
check_res = {"status": "error", "reason": f"Ошибка бота: {e}"}
|
||||
|
||||
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 user_id in pending_photos:
|
||||
del pending_photos[user_id]
|
||||
|
||||
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 isinstance(downloaded_file, bytes):
|
||||
raw_bytes = downloaded_file
|
||||
elif isinstance(downloaded_file, str) and os.path.exists(downloaded_file):
|
||||
with open(downloaded_file, 'rb') as f:
|
||||
raw_bytes = f.read()
|
||||
else:
|
||||
# Пробуем как путь
|
||||
raw_path = downloaded_file if isinstance(downloaded_file, str) else None
|
||||
if raw_path and os.path.exists(raw_path):
|
||||
with open(raw_path, 'rb') as f:
|
||||
raw_bytes = f.read()
|
||||
else:
|
||||
raise Exception(f"Не удалось получить байты файла: {type(downloaded_file)}")
|
||||
|
||||
logger.info(f"Прочитано {len(raw_bytes)} байт для {user_login}")
|
||||
|
||||
# Ищем лицо и получаем координаты
|
||||
coords = await asyncio.to_thread(get_crop_coordinates, raw_bytes)
|
||||
logger.info(f"get_crop_coordinates вернул: {coords} для {user_login}")
|
||||
|
||||
if not coords:
|
||||
await msg.answer(
|
||||
"❌ Лицо не найдено. Пожалуйста, сделайте фото анфас при хорошем освещении.\n\n"
|
||||
f"{EMOJI_DIGITS['0']} — В главное меню",
|
||||
parse_mode="html"
|
||||
)
|
||||
return
|
||||
|
||||
# Генерируем превью
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
img = Image.open(io.BytesIO(raw_bytes))
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
x1, y1, x2, y2 = coords
|
||||
cropped = img.crop((x1, y1, x2, y2))
|
||||
|
||||
# Ресайз до квадрата
|
||||
side = min(cropped.size[0], cropped.size[1])
|
||||
left = (cropped.size[0] - side) // 2
|
||||
top = (cropped.size[1] - side) // 2
|
||||
cropped = cropped.crop((left, top, left + side, top + side))
|
||||
|
||||
preview = cropped.resize((400, 400), Image.Resampling.LANCZOS)
|
||||
|
||||
if result["success"]:
|
||||
# ФИКСАЦИЯ СТАТИСТИКИ: Факт успешной загрузки и обработки нового фото
|
||||
log_menu_stats(user_id, "Photo Bot", "Загрузка и конвертация нового фото")
|
||||
pending_photos[user_id] = ready_path
|
||||
preview_path = f"/tmp/prev_{user_login}.jpg"
|
||||
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)
|
||||
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)
|
||||
preview.save(preview_path, format="JPEG", quality=85)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
preview_doc = FSInputFile(preview_path, filename="preview.jpg")
|
||||
await msg.answer_photo(preview_doc, preview_doc)
|
||||
except Exception as lib_err:
|
||||
await asyncio.to_thread(log_and_notify_photo_error, user_id, "Генерация превью", str(lib_err))
|
||||
raise Exception("Ошибка генерации превью")
|
||||
|
||||
# Сохраняем оригинал и координаты
|
||||
original_path = f"/tmp/orig_{user_login}.jpg"
|
||||
with open(original_path, 'wb') as f:
|
||||
f.write(raw_bytes)
|
||||
|
||||
pending_photos[user_id] = {
|
||||
'path': original_path,
|
||||
'coords': coords,
|
||||
'preview_path': preview_path
|
||||
}
|
||||
|
||||
log_menu_stats(user_id, "Photo Bot", "Загрузка и обработка нового фото")
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
if in_test_submode(user_id):
|
||||
await msg.answer(
|
||||
PHOTO_TEST_PREVIEW_TEXT,
|
||||
parse_mode="html"
|
||||
)
|
||||
else:
|
||||
await msg.answer(
|
||||
photo_preview_ready(),
|
||||
parse_mode="html"
|
||||
)
|
||||
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
|
||||
|
||||
+220
-91
@@ -1,127 +1,256 @@
|
||||
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)
|
||||
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": "Не удалось прочитать файл изображения."}
|
||||
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:
|
||||
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)
|
||||
x1 = max(0, x - margin)
|
||||
x2 = min(img.shape[1], x + w + margin)
|
||||
|
||||
|
||||
side = min(x2 - x1, y2 - y1)
|
||||
center_x, center_y = (x1 + x2) // 2, (y1 + y2) // 2
|
||||
crop_x1 = max(0, center_x - side // 2)
|
||||
crop_y1 = max(0, center_y - side // 2)
|
||||
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)
|
||||
logger.info(f"Лицо найдено: ({x},{y},{w},{h}), кроп: ({crop_x1},{crop_y1},{crop_x2},{crop_y2})")
|
||||
return (crop_x1, crop_y1, crop_x2, crop_y2)
|
||||
|
||||
# Добавляем немного резкости, чтобы лицо не мылилось при 96px
|
||||
final_img = final_img.filter(ImageFilter.UnsharpMask(radius=1.5, percent=100, threshold=3))
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в get_crop_coordinates: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
# 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."}
|
||||
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]
|
||||
|
||||
return {"success": True, "size_kb": round(size_kb, 1)}
|
||||
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"Ошибка в photo_processor: {e}")
|
||||
return {"success": False, "error": str(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}"}
|
||||
|
||||
Submodule
+1
Submodule sd_dispatcher added at b7b099cb38
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
import urllib3
|
||||
|
||||
BASE_URL = "https://192.168.1.237:8080"
|
||||
API_TOKEN = "062CE056-C1F5-490C-A6DF-FD2C653A71DD"
|
||||
|
||||
async def main():
|
||||
url = f"{BASE_URL}/api/v3/requests"
|
||||
headers = {
|
||||
"authtoken": API_TOKEN,
|
||||
"Accept": "application/vnd.manageengine.sdp.v3+json"
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
|
||||
resp = await client.get(url, headers=headers, params={
|
||||
"input_data": json.dumps({
|
||||
"list_info": {"row_count": 100, "start_index": 1}
|
||||
})
|
||||
})
|
||||
data = resp.json()
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(f"Has more: {data.get('list_info', {}).get('has_more_rows')}")
|
||||
requests = data.get("requests", [])
|
||||
print(f"Total: {len(requests)}")
|
||||
for r in requests:
|
||||
print(f" ID={r.get('id')} subject={r.get('subject')} created={r.get('created_time')}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
urllib3.disable_warnings()
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Проверка формата даты и вариантов фильтрации."""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
import urllib3
|
||||
|
||||
BASE_URL = "https://192.168.1.237:8080"
|
||||
API_TOKEN = "062CE056-C1F5-490C-A6DF-FD2C653A71DD"
|
||||
|
||||
async def debug():
|
||||
url = f"{BASE_URL}/api/v3/requests"
|
||||
headers = {
|
||||
"authtoken": API_TOKEN,
|
||||
"Accept": "application/vnd.manageengine.sdp.v3+json"
|
||||
}
|
||||
|
||||
# Вариант 1: без фильтра, посмотреть все поля
|
||||
print("=== Все заявки (без фильтра) ===")
|
||||
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
|
||||
resp = await client.get(url, headers=headers, params={
|
||||
"input_data": json.dumps({
|
||||
"list_info": {"row_count": 100, "start_index": 1}
|
||||
})
|
||||
})
|
||||
data = resp.json()
|
||||
requests = data.get("requests", [])
|
||||
|
||||
for req in requests:
|
||||
print(f"\n--- Заявка {req.get('request_id', '?')} ---")
|
||||
print(f" subject: {req.get('subject')}")
|
||||
print(f" created_time: {req.get('created_time')}")
|
||||
print(f" created_time type: {type(req.get('created_time')).__name__}")
|
||||
# Показать все ключи
|
||||
print(f" Ключи: {list(req.keys())}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
asyncio.run(debug())
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Поиск рабочего формата фильтрации."""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
import urllib3
|
||||
|
||||
BASE_URL = "https://192.168.1.237:8080"
|
||||
API_TOKEN = "062CE056-C1F5-490C-A6DF-FD2C653A71DD"
|
||||
|
||||
async def test_filters():
|
||||
url = f"{BASE_URL}/api/v3/requests"
|
||||
headers = {
|
||||
"authtoken": API_TOKEN,
|
||||
"Accept": "application/vnd.manageengine.sdp.v3+json"
|
||||
}
|
||||
|
||||
# Сначала посмотрим все заявки с их ID и датами
|
||||
print("=== Все заявки ===")
|
||||
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
|
||||
resp = await client.get(url, headers=headers, params={
|
||||
"input_data": json.dumps({
|
||||
"list_info": {"row_count": 100, "start_index": 1}
|
||||
})
|
||||
})
|
||||
data = resp.json()
|
||||
requests = data.get("requests", [])
|
||||
|
||||
for req in requests:
|
||||
req_id = req.get("request_id", req.get("id", "?"))
|
||||
subject = req.get("subject", "?")
|
||||
created = req.get("created_time", {})
|
||||
if isinstance(created, dict):
|
||||
created_str = created.get("display_value", "?")
|
||||
ts = created.get("value", "?")
|
||||
else:
|
||||
created_str = str(created)
|
||||
ts = "?"
|
||||
print(f" ID={req_id} created={created_str} ts={ts}")
|
||||
|
||||
# Попробуем разные форматы фильтрации
|
||||
print("\n=== Попробуем фильтры ===")
|
||||
|
||||
filters = [
|
||||
# Вариант 1: ISO строка
|
||||
{
|
||||
"search_criteria": {
|
||||
"column_name": "created_time",
|
||||
"condition": "between",
|
||||
"values": ["Jul 1, 2026 00:00:00", "Jul 31, 2026 23:59:59"]
|
||||
}
|
||||
},
|
||||
# Вариант 2: без search_criteria, с custom_field
|
||||
{
|
||||
"search_criteria": {
|
||||
"column_name": "created_time",
|
||||
"condition": "between",
|
||||
"values": ["2026-07-01", "2026-07-31"]
|
||||
}
|
||||
},
|
||||
# Вариант 3: timestamp как строка
|
||||
{
|
||||
"search_criteria": {
|
||||
"column_name": "created_time",
|
||||
"condition": "between",
|
||||
"values": ["1782864000000", "1785542399000"]
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
for i, criteria in enumerate(filters):
|
||||
params = {
|
||||
"input_data": json.dumps({
|
||||
"list_info": {"row_count": 100, "start_index": 1},
|
||||
"search_criteria": criteria.get("search_criteria")
|
||||
})
|
||||
}
|
||||
resp = await client.get(url, headers=headers, params=params)
|
||||
data = resp.json()
|
||||
reqs = data.get("requests", [])
|
||||
print(f"\nФильтр {i+1}: {criteria['search_criteria']['values']} -> {len(reqs)} заявок")
|
||||
for r in reqs:
|
||||
print(f" {r.get('id')}: {r.get('subject')}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
asyncio.run(test_filters())
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Получение списка заявок из ServiceDesk Plus через API v3."""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
import urllib3
|
||||
|
||||
BASE_URL = "https://192.168.1.237:8080"
|
||||
API_TOKEN = "062CE056-C1F5-490C-A6DF-FD2C653A71DD"
|
||||
|
||||
async def list_tickets():
|
||||
url = f"{BASE_URL}/api/v3/requests"
|
||||
headers = {
|
||||
"authtoken": API_TOKEN,
|
||||
"Accept": "application/vnd.manageengine.sdp.v3+json"
|
||||
}
|
||||
|
||||
params = {
|
||||
"input_data": json.dumps({
|
||||
"list_info": {
|
||||
"row_count": 50,
|
||||
"start_index": 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
|
||||
resp = await client.get(url, headers=headers, params=params)
|
||||
data = resp.json()
|
||||
|
||||
requests = data.get("requests", [])
|
||||
print(f"Всего заявок: {len(requests)}\n")
|
||||
|
||||
for req in requests:
|
||||
req_id = req.get("request_id", req.get("id", "?"))
|
||||
subject = req.get("subject", req.get("short_description", "?"))
|
||||
|
||||
# Группа
|
||||
group = req.get("group", {})
|
||||
if isinstance(group, dict):
|
||||
group_name = group.get("name", "?")
|
||||
else:
|
||||
group_name = str(group)
|
||||
|
||||
# Статус
|
||||
status = req.get("status", {})
|
||||
if isinstance(status, dict):
|
||||
status_name = status.get("name", "?")
|
||||
else:
|
||||
status_name = str(status)
|
||||
|
||||
# Приоритет
|
||||
priority = req.get("priority", {})
|
||||
if isinstance(priority, dict):
|
||||
priority_name = priority.get("name", "?")
|
||||
else:
|
||||
priority_name = str(priority)
|
||||
|
||||
# Описание (убираем HTML)
|
||||
desc = req.get("description", "")
|
||||
desc_clean = desc.replace("<p>", "").replace("</p>", "").replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\n")
|
||||
desc_clean = desc_clean[:300]
|
||||
|
||||
# Создатель
|
||||
requester = req.get("requester", {})
|
||||
req_name = requester.get("name", "?") if isinstance(requester, dict) else "?"
|
||||
|
||||
print(f"--- Заявка #{req_id} ---")
|
||||
print(f" Тема: {subject}")
|
||||
print(f" Группа: {group_name}")
|
||||
print(f" Статус: {status_name}")
|
||||
print(f" Приоритет: {priority_name}")
|
||||
print(f" Автор: {req_name}")
|
||||
print(f" Описание:\n {desc_clean}")
|
||||
print()
|
||||
|
||||
if __name__ == "__main__":
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
asyncio.run(list_tickets())
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Дебаг запроса к ServiceDesk Plus API v3."""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
import urllib3
|
||||
|
||||
BASE_URL = "https://192.168.1.237:8080"
|
||||
API_TOKEN = "062CE056-C1F5-490C-A6DF-FD2C653A71DD"
|
||||
|
||||
async def debug():
|
||||
url = f"{BASE_URL}/api/v3/requests"
|
||||
headers = {
|
||||
"authtoken": API_TOKEN,
|
||||
"Accept": "application/vnd.manageengine.sdp.v3+json"
|
||||
}
|
||||
|
||||
# Разные варианты формата
|
||||
variants = [
|
||||
# Вариант 1: input_data как JSON
|
||||
{
|
||||
"input_data": json.dumps({
|
||||
"list_info": {
|
||||
"row_count": 5,
|
||||
"start_index": 1
|
||||
}
|
||||
})
|
||||
},
|
||||
# Вариант 2: без input_data, просто параметры
|
||||
None,
|
||||
# Вариант 3: input_data без json.dumps
|
||||
{
|
||||
"input_data": {
|
||||
"list_info": {
|
||||
"row_count": 5,
|
||||
"start_index": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
for i, params in enumerate(variants):
|
||||
print(f"\n=== Вариант {i+1} ===")
|
||||
print(f"Params: {params}")
|
||||
|
||||
async with httpx.AsyncClient(verify=False, timeout=15.0) as client:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, params=params)
|
||||
print(f"HTTP {resp.status_code}")
|
||||
print(f"Response: {resp.text[:500]}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
asyncio.run(debug())
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Получение списка заявок из ServiceDesk Plus с 20 июля 2026. Экспорт: Группа, Описание, Назначено, Категория в JSONL."""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
import urllib3
|
||||
from datetime import datetime, timezone
|
||||
from html import unescape
|
||||
import re
|
||||
|
||||
BASE_URL = "https://192.168.1.237:8080"
|
||||
API_TOKEN = "062CE056-C1F5-490C-A6DF-FD2C653A71DD"
|
||||
|
||||
FROM_DATE_MS = int(datetime(2026, 7, 20, 0, 0, 0, tzinfo=timezone.utc).timestamp() * 1000)
|
||||
|
||||
def get_ts(req):
|
||||
ct = req.get("created_time", {})
|
||||
if isinstance(ct, dict):
|
||||
v = ct.get("value", "0")
|
||||
return int(v) if v else 0
|
||||
return 0
|
||||
|
||||
def clean_html(text):
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = unescape(text)
|
||||
return text.strip()
|
||||
|
||||
def get_name(obj):
|
||||
"""Извлечь имя из объекта группы/техника/категории."""
|
||||
if not obj:
|
||||
return ""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get("name", "") or obj.get("display_value", "")
|
||||
return str(obj)
|
||||
|
||||
async def fetch_ticket_detail(client, req_id):
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{BASE_URL}/api/v3/requests/{req_id}",
|
||||
headers={"authtoken": API_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"},
|
||||
timeout=15.0
|
||||
)
|
||||
data = resp.json()
|
||||
return data.get("request", data)
|
||||
except Exception as e:
|
||||
return {}
|
||||
|
||||
async def list_tickets():
|
||||
url = f"{BASE_URL}/api/v3/requests"
|
||||
headers = {"authtoken": API_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"}
|
||||
|
||||
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
data = resp.json()
|
||||
all_reqs = data.get("requests", [])
|
||||
|
||||
from_20 = [r for r in all_reqs if get_ts(r) >= FROM_DATE_MS]
|
||||
|
||||
# Получаем детали для каждой заявки
|
||||
tasks = [fetch_ticket_detail(client, r.get("request_id", r.get("id", "?"))) for r in from_20]
|
||||
details = await asyncio.gather(*tasks)
|
||||
|
||||
rows = []
|
||||
for req, detail in zip(from_20, details):
|
||||
# Группа — из списка (есть там)
|
||||
group = req.get("group", {})
|
||||
group_name = get_name(group)
|
||||
|
||||
# Описание — из детального запроса
|
||||
desc = detail.get("description", "")
|
||||
desc_clean = clean_html(desc)
|
||||
|
||||
# Назначено (специалист) — из детального запроса
|
||||
tech = detail.get("technician", {})
|
||||
assignee = get_name(tech)
|
||||
|
||||
# Категория — из детального запроса
|
||||
cat = detail.get("category", {})
|
||||
category = get_name(cat)
|
||||
|
||||
rows.append({
|
||||
"group": group_name,
|
||||
"description": desc_clean,
|
||||
"assignee": assignee,
|
||||
"category": category
|
||||
})
|
||||
|
||||
# Выводим JSONL
|
||||
for row in rows:
|
||||
print(json.dumps(row, ensure_ascii=False))
|
||||
|
||||
if __name__ == "__main__":
|
||||
urllib3.disable_warnings()
|
||||
asyncio.run(list_tickets())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+265
-40
@@ -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)
|
||||
|
||||
|
||||
# =========================================================
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
# /opt/trueconf_bot/utils/texts.py
|
||||
# Все текстовые сообщения бота, отправляемые пользователю
|
||||
|
||||
# =========================================================
|
||||
# GLOBAL CONSTANTS
|
||||
# =========================================================
|
||||
|
||||
# --- Main Bot ---
|
||||
# Общее для главного бота
|
||||
UNKNOWN_MAIN_CMD_TEXT = (
|
||||
"⚠️ <b>Неизвестная команда.</b>\n\n"
|
||||
"Пожалуйста, используйте только цифры, соответствующие нужным пунктам меню, и отправьте выбранную цифру мне в сообщении."
|
||||
)
|
||||
|
||||
# --- Emoji Digits ---
|
||||
# Используются для навигации и выбора пунктов меню
|
||||
EMOJI_DIGITS = {
|
||||
"0": "0⃣",
|
||||
"1": "1⃣",
|
||||
"2": "2⃣",
|
||||
"3": "3⃣",
|
||||
"4": "4⃣",
|
||||
"5": "5⃣",
|
||||
"6": "6⃣",
|
||||
"7": "7⃣",
|
||||
"8": "8⃣",
|
||||
"9": "9⃣",
|
||||
"10": "🔟"
|
||||
}
|
||||
|
||||
# --- Footer Templates ---
|
||||
# Шаблоны подвалов сообщений для навигации
|
||||
|
||||
def _footer(emojis: dict) -> str:
|
||||
"""Стандартный футер с кнопками Назад и Меню"""
|
||||
return f"\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _footer_main_only(emojis: dict) -> str:
|
||||
"""Футер только с кнопкой Меню"""
|
||||
return f"\n\n<i>{emojis['0']} — В главное меню</i>"
|
||||
|
||||
|
||||
# =========================================================
|
||||
# TRANSCRIPTION BOT
|
||||
# =========================================================
|
||||
# Модуль для расшифровки аудио и видео файлов
|
||||
|
||||
# --- Private functions (Internal logic) ---
|
||||
|
||||
def _transcription_welcome(emojis: dict) -> str:
|
||||
return (
|
||||
f"🎙 <b>Добро пожаловать! Я — Ассистент расшифровки совещаний.</b>\n\n"
|
||||
f"Я помогу вам перевести аудио или видео в текст, а также подготовлю краткое резюме вашей встречи.\n\n"
|
||||
f"📎 Для начала работы просто отправьте нужный файл прямо в этот чат.\n\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _transcription_unknown(emojis: dict) -> str:
|
||||
return (
|
||||
f"⚠️ <b>Неизвестная команда.</b>\n\n"
|
||||
f"Пожалуйста, отправьте в сообщении мне нужную цифру соответствующего пункта меню в чат."
|
||||
)
|
||||
|
||||
def _transcription_bad_extension(ext: str, emojis: dict) -> str:
|
||||
return (
|
||||
f"❌ <b>Ошибка: Недопустимый формат файла ({ext}).</b>\n\n"
|
||||
f"Пожалуйста, отправьте поддерживаемый аудио или видео файл.\n\n"
|
||||
f"{_footer(emojis)}"
|
||||
)
|
||||
|
||||
def _transcription_uploading() -> str:
|
||||
return "⏳ <b>Загружаю файл...</b>"
|
||||
|
||||
def _transcription_success(email: str, emojis: dict) -> str:
|
||||
return (
|
||||
f"✅ <b>Хорошо, файл принят в обработку.</b>\n\n"
|
||||
f"Как только всё будет готово, я отправлю вам результаты на почту (<code>{email}</code>).\n\n"
|
||||
f"⏳ Обычно это занимает от 5 до 30 минут.\n\n"
|
||||
f"{_footer(emojis)}"
|
||||
)
|
||||
|
||||
def _transcription_api_error(status_code: int, emojis: dict) -> str:
|
||||
return f"❌ <b>Ошибка API сервера ({status_code}).</b>{_footer(emojis)}"
|
||||
|
||||
def _transcription_system_error(emojis: dict) -> str:
|
||||
return f"❌ <b>Произошла системная ошибка при передаче файла.</b>{_footer(emojis)}"
|
||||
|
||||
# --- Public constants and functions ---
|
||||
|
||||
TRANSCRIPTION_MAIN_MENU_TEXT = _transcription_welcome(EMOJI_DIGITS)
|
||||
TRANSCRIPTION_UNKNOWN_CMD_TEXT = _transcription_unknown(EMOJI_DIGITS)
|
||||
|
||||
def transcription_error_bad_extension(ext: str) -> str:
|
||||
return _transcription_bad_extension(ext, EMOJI_DIGITS)
|
||||
|
||||
TRANSCRIPTION_UPLOADING_TEXT = _transcription_uploading()
|
||||
|
||||
def transcription_success_received(email: str) -> str:
|
||||
return _transcription_success(email, EMOJI_DIGITS)
|
||||
|
||||
def transcription_error_api(status_code: int) -> str:
|
||||
return _transcription_api_error(status_code, EMOJI_DIGITS)
|
||||
|
||||
def transcription_error_system() -> str:
|
||||
return _transcription_system_error(EMOJI_DIGITS)
|
||||
|
||||
|
||||
# =========================================================
|
||||
# SERVICE DESK BOT
|
||||
# =========================================================
|
||||
# Модуль для создания и управления заявками в Service Desk
|
||||
|
||||
# --- Private functions (Internal logic) ---
|
||||
|
||||
def _sd_welcome(emojis: dict) -> str:
|
||||
return (
|
||||
f"🎫 <b>Режим создания заявки в Service Desk</b>\n\n"
|
||||
f"• Опишите проблему текстом.\n"
|
||||
f"• Можно прикрепить фото или документ.\n\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _sd_unknown(emojis: dict) -> str:
|
||||
return (
|
||||
f"⚠️ <b>Неизвестная команда.</b>\n"
|
||||
f"Пожалуйста, используйте цифры меню из сообщения выше.\n\n"
|
||||
f"{emojis['9']} — Назад\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _sd_text_required(emojis: dict) -> str:
|
||||
return f"⚠️ <b>Сначала опишите проблему текстом.</b>"
|
||||
|
||||
def _sd_creating() -> str:
|
||||
return "⏳ Создаю заявку, пожалуйста, подождите..."
|
||||
|
||||
def _sd_ticket_created(ticket_id: int, subject: str, description: str, emojis: dict) -> str:
|
||||
clean_desc = description.replace("\n", " ").strip()
|
||||
if len(clean_desc) > 150:
|
||||
clean_desc = clean_desc[:147] + "..."
|
||||
return (
|
||||
f"✅ <b>Заявка #{ticket_id} создана!</b>\n\n"
|
||||
f"📌 <b>Тема: {subject}</b>\n\n"
|
||||
f"📝 <b>Текст: {clean_desc}</b>\n\n"
|
||||
f"Если нужно добавить скриншоты или документ — отправьте их сейчас.\n\n"
|
||||
f"{_footer(emojis)}"
|
||||
)
|
||||
|
||||
def _sd_ticket_create_error(emojis: dict) -> str:
|
||||
return f"❌ <b>Ошибка создания заявки.</b>{_footer_main_only(emojis)}"
|
||||
|
||||
def _sd_system_error(emojis: dict) -> str:
|
||||
return f"❌ <b>Системная ошибка.</b> Попробуйте позже.{_footer_main_only(emojis)}"
|
||||
|
||||
def _sd_uploading_file(ticket_id: int, emojis: dict) -> str:
|
||||
return f"⏳ Загружаю файл в заявку #{ticket_id}..."
|
||||
|
||||
def _sd_file_attached(emojis: dict) -> str:
|
||||
return f"✅ <b>Файл успешно прикреплен!</b>{_footer(emojis)}"
|
||||
|
||||
def _sd_file_upload_error(emojis: dict) -> str:
|
||||
return f"❌ <b>Ошибка загрузки файла.</b>{_footer_main_only(emojis)}"
|
||||
|
||||
# --- Public constants and functions ---
|
||||
|
||||
SD_MAIN_MENU_TEXT = _sd_welcome(EMOJI_DIGITS)
|
||||
SD_UNKNOWN_CMD_TEXT = _sd_unknown(EMOJI_DIGITS)
|
||||
SD_TEXT_REQUIRED = _sd_text_required(EMOJI_DIGITS)
|
||||
SD_CREATING = _sd_creating()
|
||||
|
||||
def sd_ticket_created(ticket_id: int, subject: str, description: str) -> str:
|
||||
return _sd_ticket_created(ticket_id, subject, description, EMOJI_DIGITS)
|
||||
|
||||
def sd_ticket_create_error() -> str:
|
||||
return _sd_ticket_create_error(EMOJI_DIGITS)
|
||||
|
||||
def sd_system_error() -> str:
|
||||
return _sd_system_error(EMOJI_DIGITS)
|
||||
|
||||
def sd_uploading_file(ticket_id: int) -> str:
|
||||
return _sd_uploading_file(ticket_id, EMOJI_DIGITS)
|
||||
|
||||
def sd_file_attached() -> str:
|
||||
return _sd_file_attached(EMOJI_DIGITS)
|
||||
|
||||
def sd_file_upload_error() -> str:
|
||||
return _sd_file_upload_error(EMOJI_DIGITS)
|
||||
|
||||
|
||||
# =========================================================
|
||||
# PHOTO BOT
|
||||
# =========================================================
|
||||
# Модуль для обновления фото профиля
|
||||
|
||||
# --- Private functions (Internal logic) ---
|
||||
|
||||
def _photo_welcome(emojis: dict) -> str:
|
||||
return (
|
||||
f"🖼 <b>Режим обновления фото профиля</b>\n\n"
|
||||
f"📸 Пришлите мне вашу фотографию.\n\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _photo_unknown(emojis: dict) -> str:
|
||||
return (
|
||||
f"⚠️ <b>Неизвестная команда.</b>\n"
|
||||
f"Пожалуйста, используйте цифры меню из сообщения выше.\n\n"
|
||||
f"{emojis['9']} — Назад\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _photo_error_handling(emojis: dict) -> str:
|
||||
return (
|
||||
f"⚠️ <i>В данный момент выполнить это действие невозможно. "
|
||||
f"Информация о проблеме передана ответственным.\n"
|
||||
f"Приносим извинения за доставленные неудобства.</i>\n\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _photo_uploading() -> str:
|
||||
return "⏳ Отправка..."
|
||||
|
||||
def _photo_success_sent() -> str:
|
||||
return "📩 <b>Фото отправлено на утверждение.</b>"
|
||||
|
||||
def _photo_cancel(emojis: dict) -> str:
|
||||
return (
|
||||
f"🚫 Отменено. Пришлите другое фото.\n\n"
|
||||
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_UNKNOWN_CMD_TEXT = _photo_unknown(EMOJI_DIGITS)
|
||||
|
||||
def photo_error_handling() -> str:
|
||||
return _photo_error_handling(EMOJI_DIGITS)
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# =========================================================
|
||||
# SEARCH BOT
|
||||
# =========================================================
|
||||
# Модуль поиска по регламентам
|
||||
|
||||
# --- Private functions (Internal logic) ---
|
||||
|
||||
def _search_welcome(emojis: dict) -> str:
|
||||
return (
|
||||
f"📚 <b>Поиск по регламентам</b>\n\n"
|
||||
f"Введите ваш вопрос (например: <i>'время хранения электронных документов'</i> ).\n"
|
||||
f"Бот найдет наиболее подходящие абзацы из нормативных документов.\n\n"
|
||||
f"Для возврата в главное меню отправьте {emojis['0']}."
|
||||
)
|
||||
|
||||
def _search_unknown(emojis: dict) -> str:
|
||||
return (
|
||||
f"⚠️ <b>Неизвестная команда.</b>\n"
|
||||
f"Пожалуйста, используйте цифры меню из сообщения выше.\n\n"
|
||||
f"{emojis['9']} — Назад\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _search_searching() -> str:
|
||||
return "🔍 <b>Ищу информацию в регламентах...</b>"
|
||||
|
||||
def _search_not_found(emojis: dict) -> str:
|
||||
return (
|
||||
f"🤖 Точной информации в регламентах не найдено.\n\n"
|
||||
f"Для выхода отправьте {emojis['0']}."
|
||||
)
|
||||
|
||||
def _search_feedback_thanks(emojis: dict) -> str:
|
||||
return (
|
||||
f"🙏 <b>Спасибо за ваш отзыв!</b> Мы передали его разработчикам.\n\n"
|
||||
f"🔍 <b>Можете сразу задать следующий вопрос по регламентам.</b>\n\n"
|
||||
f"Для выхода отправьте {emojis['0']}."
|
||||
)
|
||||
|
||||
def _search_feedback_info(emojis: dict) -> str:
|
||||
return (
|
||||
f"⚠️ <b>Информация передана.</b>\n\n"
|
||||
f"Если желаете, напишите прямо сейчас текстовым сообщением, что конкретно вам не понравилось в ответе.\n\n"
|
||||
f"{emojis['1']} — Задать ещё вопрос\n"
|
||||
f"{emojis['0']} — Выйти в главное меню"
|
||||
)
|
||||
|
||||
def _search_unavailable(emojis: dict) -> str:
|
||||
return (
|
||||
"⚠️ <i>В данный момент выполнить это действие невозможно. "
|
||||
"Информация о проблеме передана ответственным.\n"
|
||||
"Приносим извинения за доставленные неудобства.</i>\n\n"
|
||||
f"<i>{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
# --- Public constants and functions ---
|
||||
|
||||
SEARCH_MAIN_MENU_TEXT = _search_welcome(EMOJI_DIGITS)
|
||||
SEARCH_UNKNOWN_CMD_TEXT = _search_unknown(EMOJI_DIGITS)
|
||||
|
||||
def search_searching_text() -> str:
|
||||
return _search_searching()
|
||||
|
||||
def search_not_found_text(emojis: dict) -> str:
|
||||
return _search_not_found(emojis)
|
||||
|
||||
def search_feedback_thanks_text(emojis: dict) -> str:
|
||||
return _search_feedback_thanks(emojis)
|
||||
|
||||
def search_feedback_info_text(emojis: dict) -> str:
|
||||
return _search_feedback_info(emojis)
|
||||
|
||||
def search_unavailable_action(emojis: dict) -> str:
|
||||
return _search_unavailable(emojis)
|
||||
|
||||
|
||||
# =========================================================
|
||||
# LK BOT
|
||||
# =========================================================
|
||||
# Модуль Личного Кабинета (финансовые документы, табель и др.)
|
||||
|
||||
# --- Constants ---
|
||||
|
||||
DOC_TYPES = {
|
||||
"1": "Справка о доходах физического лица (2-НДФЛ)",
|
||||
"2": "Справка об удержаниях за ДМС",
|
||||
"3": "Справка о средней заработной плате",
|
||||
"4": "Справка о получении единовременного пособия по рождению ребенка",
|
||||
"5": "Справка о получении ежемесячного пособия и назначении отпуска по уходу за ребенком",
|
||||
"6": "Справка об удержаниях по исполнительным листам",
|
||||
"7": "Копия трудовой книжки"
|
||||
}
|
||||
|
||||
# --- Private functions (Internal logic) ---
|
||||
|
||||
def _lk_error_handling(emojis: dict) -> str:
|
||||
return (
|
||||
"⚠️ <i>В данный момент выполнить это действие невозможно. Информация о проблеме передана ответственным. Приносим извинения за доставленные неудобства.</i>\n\n"
|
||||
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
def _lk_auth_processing(emojis: dict) -> str:
|
||||
return "⏳ <i>Проверка безопасности... Запрашиваю контакты авторизации...</i>"
|
||||
|
||||
def _lk_auth_success(emojis: dict) -> str:
|
||||
return "⏳ <i>Авторизация успешна. Загружаю детализацию расчетного листа...</i>"
|
||||
|
||||
def _lk_auth_2fa_expired(emojis: dict) -> str:
|
||||
return f"⏱ <b>Время действия кода подтверждения изменилось или истекло.</b>\nПожалуйста, запросите расчетный лист заново.{emojis['9']} — Назад"
|
||||
|
||||
def _lk_auth_2fa_wrong_code(emojis: dict, attempts_left: int) -> str:
|
||||
return (
|
||||
f"⚠️ <b>Неверный код подтверждения.</b>\nОсталось попыток: {attempts_left}. Попробуйте еще раз или вернитесь назад.\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
def _lk_auth_2fa_blocked_max(emojis: dict, minutes: int) -> str:
|
||||
return (
|
||||
f"❌ <b>Превышено максимальное количество попыток ввода кода.</b>\nДоступ к финансовым документам заблокирован на <b>{minutes} минут</b>.<br>\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
def _lk_payslip_loading(emojis: dict) -> str:
|
||||
return "⏳ <i>Формирую архив расчетных листков...</i>"
|
||||
|
||||
def _lk_vacation_loading(emojis: dict) -> str:
|
||||
return "⏳ <i>Формирую справку по отпускам...</i>"
|
||||
|
||||
def _lk_sick_loading(emojis: dict) -> str:
|
||||
return "⏳ <i>Загружаю историю больничных листов...</i>"
|
||||
|
||||
def _lk_missions_loading(emojis: dict) -> str:
|
||||
return "⏳ <i>Загружаю историю командировок...</i>"
|
||||
|
||||
def _lk_rewards_loading(emojis: dict) -> str:
|
||||
return "⏳ <i>Ищу информацию о ваших наградах...</i>"
|
||||
|
||||
def _lk_transfers_loading(emojis: dict) -> str:
|
||||
return "⏳ <i>Загружаю историю кадровых перемещений...</i>"
|
||||
|
||||
def _lk_tabel_loading(emojis: dict) -> str:
|
||||
return "⏳ <i>Рассчитываю баланс рабочего времени...</i>"
|
||||
|
||||
def _lk_auth_sending(emojis: dict) -> str:
|
||||
return "⏳ <i>Отправка заявки...</i>"
|
||||
|
||||
def _lk_success_submission(emojis: dict) -> str:
|
||||
return (
|
||||
"✅ <b>Ваша заявка успешно отправлена!</b>\n"
|
||||
"Специалисты свяжутся с вами по готовности документа.\n\n"
|
||||
f"<i>{emojis['9']} — Назад в список справок\n{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
def _lk_main_menu_text(header: str, emojis: dict) -> str:
|
||||
return (
|
||||
f"{header}\n"
|
||||
f"{emojis['1']} — Расчётный лист\n"
|
||||
f"{emojis['2']} — Информация о днях отпуска\n"
|
||||
f"{emojis['3']} — Больничные\n"
|
||||
f"{emojis['4']} — Командировки\n"
|
||||
f"{emojis['5']} — Награды\n"
|
||||
f"{emojis['6']} — Переводы\n"
|
||||
f"{emojis['7']} — Табель учёта рабочего времени\n"
|
||||
f"{emojis['8']} — Справки\n\n"
|
||||
f"<i>{emojis['0']} — Вернуться в главное меню</i>"
|
||||
)
|
||||
|
||||
def _lk_tabel_months_menu_text(prev_1, prev_2, months_ru, emojis: dict) -> str:
|
||||
return (
|
||||
"⏱ <b>Выберите месяц для просмотра табеля:</b>\n\n"
|
||||
f"{emojis['1']} — {months_ru[prev_1.month]} {prev_1.year} г.\n"
|
||||
f"{emojis['2']} — {months_ru[prev_2.month]} {prev_2.year} г.\n\n"
|
||||
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
def _lk_payslip_months_menu_text(months_data: list, months_ru: dict, emojis: dict) -> str:
|
||||
menu_text = "📅 <b>Выберите месяц для просмотра:</b>\n\n"
|
||||
for idx, cmonth in enumerate(months_data, start=1):
|
||||
month_name = months_ru.get((cmonth - 1) % 12 + 1, '00')
|
||||
year = (cmonth - 1) // 12
|
||||
menu_text += f"{emojis[str(idx)]} — {month_name} {year} г.\n"
|
||||
menu_text += f"\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
return menu_text
|
||||
|
||||
def _lk_docs_quantity_prompt(emojis: dict) -> str:
|
||||
return f"Укажите необходимое количество экземпляров (максимум — 5 шт.):\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _lk_docs_year_prompt(emojis: dict) -> str:
|
||||
return f"📅 <b>Выберите год, за который требуется справка:</b>\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _lk_years_menu(current_year: int, emojis: dict) -> str:
|
||||
lines = ["📅 <b>Выберите год запрашиваемого периода:</b>\n"]
|
||||
for i in range(1, 9):
|
||||
year = current_year - (i - 1)
|
||||
emoji = emojis.get(str(i), f"{i}⃣")
|
||||
lines.append(f"{emoji} — {year} год")
|
||||
lines.append(f"\n<i>{emojis.get('9', '9⃣')} — Назад\n{emojis.get('0', '0⃣')} — В главное меню</i>")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _lk_docs_avg_salary_prompt(emojis: dict) -> str:
|
||||
return f"Укажите количество месяцев для расчета средней ЗП (например: 3):\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _lk_docs_child_age_prompt(emojis: dict) -> str:
|
||||
return f"👶 <b>Выберите возраст ребенка (до скольки лет):</b>\n\n{emojis['1']} — до 1.5 лет\n{emojis['2']} — до 3 лет\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _lk_docs_period_prompt(year: int, emojis: dict) -> str:
|
||||
return f"за {year} год\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _lk_docs_child_age_options(emojis: dict) -> str:
|
||||
return f"{emojis['1']} — до 1.5 лет\n{emojis['2']} — до 3 лет\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _lk_docs_period_options(emojis: dict) -> str:
|
||||
return f"{emojis['1']} — до 1.5 лет\n{emojis['2']} — до 3 лет\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _lk_nav_text(emojis: dict) -> str:
|
||||
return f"\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
# --- Public constants and functions ---
|
||||
|
||||
LK_DOCS_MENU_TEXT = (
|
||||
"\n".join([f"{EMOJI_DIGITS[k]} — {v}" for k, v in DOC_TYPES.items()]) +
|
||||
f"\n\n<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
|
||||
)
|
||||
LK_UNKNOWN_CMD_TEXT = (
|
||||
"⚠️ <b>Неизвестная команда.</b>\n"
|
||||
"Пожалуйста, выберите пункт меню, используя цифры или кнопки.<br>\n"
|
||||
f"<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
|
||||
def lk_error_handling(emojis: dict) -> str:
|
||||
return _lk_error_handling(emojis)
|
||||
|
||||
def lk_auth_processing(emojis: dict) -> str:
|
||||
return _lk_auth_processing(emojis)
|
||||
|
||||
def lk_auth_success(emojis: dict) -> str:
|
||||
return _lk_auth_success(emojis)
|
||||
|
||||
def lk_auth_2fa_expired(emojis: dict) -> str:
|
||||
return _lk_auth_2fa_expired(emojis)
|
||||
|
||||
def lk_auth_2fa_wrong_code(emojis: dict, attempts_left: int) -> str:
|
||||
return _lk_auth_2fa_wrong_code(emojis, attempts_left)
|
||||
|
||||
def lk_auth_2fa_blocked_max(emojis: dict, minutes: int) -> str:
|
||||
return _lk_auth_2fa_blocked_max(emojis, minutes)
|
||||
|
||||
def lk_payslip_loading(emojis: dict) -> str:
|
||||
return _lk_payslip_loading(emojis)
|
||||
|
||||
def lk_vacation_loading(emojis: dict) -> str:
|
||||
return _lk_vacation_loading(emojis)
|
||||
|
||||
def lk_sick_loading(emojis: dict) -> str:
|
||||
return _lk_sick_loading(emojis)
|
||||
|
||||
def lk_missions_loading(emojis: dict) -> str:
|
||||
return _lk_missions_loading(emojis)
|
||||
|
||||
def lk_rewards_loading(emojis: dict) -> str:
|
||||
return _lk_rewards_loading(emojis)
|
||||
|
||||
def lk_transfers_loading(emojis: dict) -> str:
|
||||
return _lk_transfers_loading(emojis)
|
||||
|
||||
def lk_tabel_loading(emojis: dict) -> str:
|
||||
return _lk_tabel_loading(emojis)
|
||||
|
||||
def lk_auth_sending(emojis: dict) -> str:
|
||||
return _lk_auth_sending(emojis)
|
||||
|
||||
def lk_success_submission(emojis: dict) -> str:
|
||||
return _lk_success_submission(emojis)
|
||||
|
||||
def lk_main_menu_text(header: str, emojis: dict) -> str:
|
||||
return _lk_main_menu_text(header, emojis)
|
||||
|
||||
def lk_tabel_months_menu_text(prev_1, prev_2, months_ru, emojis: dict) -> str:
|
||||
return _lk_tabel_months_menu_text(prev_1, prev_2, months_ru, emojis)
|
||||
|
||||
def lk_payslip_months_menu_text(months_data: list, months_ru: dict, emojis: dict) -> str:
|
||||
return _lk_payslip_months_menu_text(months_data, months_ru, emojis)
|
||||
|
||||
def lk_docs_quantity_prompt(emojis: dict) -> str:
|
||||
return _lk_docs_quantity_prompt(emojis)
|
||||
|
||||
def lk_docs_year_prompt(emojis: dict) -> str:
|
||||
return _lk_docs_year_prompt(emojis)
|
||||
|
||||
def lk_docs_avg_salary_prompt(emojis: dict) -> str:
|
||||
return _lk_docs_avg_salary_prompt(emojis)
|
||||
|
||||
def lk_docs_child_age_prompt(emojis: dict) -> str:
|
||||
return _lk_docs_child_age_prompt(emojis)
|
||||
|
||||
def lk_docs_period_prompt(year: int, emojis: dict) -> str:
|
||||
return _lk_docs_period_prompt(year, emojis)
|
||||
|
||||
def lk_docs_child_age_options(emojis: dict) -> str:
|
||||
return _lk_docs_child_age_options(emojis)
|
||||
|
||||
def lk_docs_period_options(emojis: dict) -> str:
|
||||
return _lk_docs_period_options(emojis)
|
||||
|
||||
def lk_nav_text(emojis: dict) -> str:
|
||||
return _lk_nav_text(emojis)
|
||||
|
||||
def lk_years_menu(current_year: int, emojis: dict) -> str:
|
||||
return _lk_years_menu(current_year, emojis)
|
||||
|
||||
|
||||
# =========================================================
|
||||
# INSTRUCT BOT (Инструкции)
|
||||
# =========================================================
|
||||
# Модуль инструкций
|
||||
|
||||
# --- Private functions (Internal logic) ---
|
||||
|
||||
def _instruct_main_menu(emojis: dict) -> str:
|
||||
return (
|
||||
f"📋 <b>Выберите инструкцию:</b>\n\n"
|
||||
f"{emojis['1']} — 🖥 Trueconf\n"
|
||||
f"{emojis['2']} — 📧 Почта\n\n"
|
||||
f"<i>{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
def _instruct_trueconf(emojis: dict) -> str:
|
||||
"""Статический текст Trueconf — без проверки группы."""
|
||||
return (
|
||||
"🖥 <b>Инструкция по Trueconf</b>\n\n"
|
||||
"Данная функция находится в разработке.\n\n"
|
||||
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
|
||||
def _instruct_trueconf_with_ad(emojis: dict, cn: str, ticket_id: str = None) -> str:
|
||||
"""
|
||||
Текст Trueconf с проверкой группы 2FA.
|
||||
cn — CN пользователя из AD.
|
||||
ticket_id — номер созданной заявки в SD.
|
||||
"""
|
||||
from utils.ad_checker import check_group_membership
|
||||
|
||||
result = check_group_membership(cn, "2FA")
|
||||
|
||||
if not result["found"]:
|
||||
ticket_part = f"\n\n🎫 <b>Заявка #{ticket_id} создана!</b>" if ticket_id else ""
|
||||
return (
|
||||
f"🖥 <b>Инструкция по Trueconf</b>\n\n"
|
||||
f"Пользователь {cn} не найден в Active Directory.{ticket_part}\n\n"
|
||||
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
if result["in_group"]:
|
||||
body = (
|
||||
f"🖥 <b>Инструкция по Trueconf</b>\n\n"
|
||||
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
else:
|
||||
body = (
|
||||
f"ℹ <b>Доступ к Trueconf пока не настроен.</b>\n\n"
|
||||
f"🎫 Заявка #23651 для настройки создана в ServiceDesk!\n\n"
|
||||
f"🔄 После выполнения заявки запросите инструкцию ещё раз.\n\n"
|
||||
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
return body
|
||||
|
||||
|
||||
def _instruct_email(emojis: dict) -> str:
|
||||
return (
|
||||
"📧 <b>Инструкция по Почте</b>\n\n"
|
||||
"Данная функция находится в разработке.\n\n"
|
||||
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
# --- Public constants and functions ---
|
||||
|
||||
INSTRUCT_MAIN_MENU_TEXT = _instruct_main_menu(EMOJI_DIGITS)
|
||||
INSTRUCT_TRUECONF_TEXT = _instruct_trueconf(EMOJI_DIGITS)
|
||||
INSTRUCT_TRUECONF_WITH_AD = _instruct_trueconf_with_ad
|
||||
INSTRUCT_EMAIL_TEXT = _instruct_email(EMOJI_DIGITS)
|
||||
Reference in New Issue
Block a user