Compare commits
198 Commits
service_desk
..
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 | |||
| 9a9b90b3a1 | |||
| 95803b2c3c | |||
| 809f5f4980 | |||
| fbe9c042ce | |||
| 508bfd36df | |||
| 941cd569d8 | |||
| 7c2a3f1c4a | |||
| a9a4926e3e | |||
| 427f6ed000 | |||
| cc9d3ca78a | |||
| f0d927bcb6 | |||
| d2e7dd6234 | |||
| 9e34b0597b | |||
| 4db994f6de | |||
| ed669b6aac | |||
| cecec601e0 | |||
| 6c466dc0f6 | |||
| 974ecbb36d | |||
| a189c361d2 | |||
| 613102d0c9 | |||
| 4a464911d6 | |||
| 2fe74b08d8 | |||
| 5f1c023652 | |||
| 4fbca23c6b | |||
| edbb1bbc95 | |||
| 0973b83ba0 | |||
| 996cfc7ad3 | |||
| 81714f4bc2 | |||
| 6366f2a503 | |||
| c7e0fd8cc6 | |||
| d3f6206113 | |||
| 3d6c99ed24 | |||
| 03808a553a | |||
| 6f187591b5 | |||
| 6c43c4315e | |||
| 1e4770ea16 | |||
| c5edcbdd06 | |||
| a3b9796fe8 | |||
| aaf0c66ed6 | |||
| 0fa11655d5 | |||
| 7523a4849e | |||
| bfb3a0f359 | |||
| 9f737d3282 | |||
| e550e657fe | |||
| 3e98471e21 | |||
| 042ef92e98 | |||
| 6f0f40a29e | |||
| 3555d378b2 | |||
| e1a772ba0c | |||
| 597aa56651 | |||
| 5f604e52d8 | |||
| 1d8b4ef3c1 | |||
| 17cf93eed9 | |||
| 1ab303ca60 | |||
| 2c5b296eaf | |||
| 8d4dcb006e | |||
| 90b4d554bb | |||
| 1585206707 | |||
| 03c0d6f759 | |||
| 9ccda24a55 | |||
| 8bb0e27028 | |||
| 8aea92b84f | |||
| dd14853362 | |||
| c42854c3a5 | |||
| 6ad50dcd4d | |||
| 84a03f79ee | |||
| ddbcb11685 | |||
| 80b15c86c9 | |||
| 36dbae5a56 | |||
| 708684fdfe | |||
| baeac6b1cc | |||
| 09a2ccc6b4 | |||
| e07b0af6b4 | |||
| be16c4fe17 | |||
| cc71f00506 | |||
| 9a24f48259 | |||
| 9b65ec1e7e | |||
| 354c6b0a51 | |||
| 1ac0035b4d | |||
| c987f14cf9 | |||
| f1709f16b6 | |||
| 0cfef14722 | |||
| d2fe4aa1dc | |||
| 0ff681b84f | |||
| 3f239267d2 | |||
| 94bd664be8 | |||
| c6bc67a98d | |||
| 005b728d7c | |||
| 866654a132 | |||
| 3c160afe20 | |||
| 347a909e06 | |||
| dff39f025c | |||
| 523ed89519 | |||
| d1ad0b16a1 | |||
| 6a6d59394a | |||
| 32f1cd9513 | |||
| 51b632ec7d | |||
| 54b019f3b9 | |||
| 7c900e5502 | |||
| 7d67e3ffc2 | |||
| af0caee963 | |||
| 0dca28ef20 | |||
| de47c39e76 | |||
| f0be37b18f | |||
| 9aedbd4c1f | |||
| 977e7369e3 | |||
| 23a7d20722 | |||
| 1caae96a97 | |||
| 90bac4e343 | |||
| 7517448508 | |||
| f98ba35a60 | |||
| 0ff328694c | |||
| 04430e56ef | |||
| 0b76fbaf17 | |||
| f4c21c825c | |||
| 3fa7543477 | |||
| 064451dc7c | |||
| c76914444c | |||
| 09b19f9ced | |||
| 9f76064826 | |||
| 49a986fa65 | |||
| 2c39e5b50b | |||
| 26b58f2909 | |||
| d9528486ef | |||
| 525899557b | |||
| 03339885ff | |||
| 8fa42206aa | |||
| be12045fa8 | |||
| 7c0aaf5392 | |||
| 55a95889b0 | |||
| 8029f7fb2d | |||
| 2fa4ff2781 | |||
| aadbaab321 | |||
| 54f0d074ba | |||
| 642f2bba6a | |||
| 77cbb1d352 | |||
| 8e686b1ca3 | |||
| d30ceeda6b | |||
| 0168a8b0a5 | |||
| 260b6c3a8a |
Binary file not shown.
+109
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Проверка наличия пользователя в группе 2FA через Active Directory.
|
||||||
|
Использует credentials и настройки из config/config.py.
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
python check_ad_2fa.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from ldap3 import Server, Connection, ALL, SUBTREE
|
||||||
|
|
||||||
|
# Подтягиваем конфиг
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASES
|
||||||
|
from utils.ad_search import search_by_filter as ad_search_all_bases
|
||||||
|
|
||||||
|
|
||||||
|
def find_user(login: str) -> dict | None:
|
||||||
|
"""Найти пользователя в AD по логину (sAMAccountName или mail).
|
||||||
|
Ищет по всем OU из AD_BASES."""
|
||||||
|
filter_login = login.lower().replace("\\", "\\5c").replace("*", "\\2a").replace("(", "\\28").replace(")", "\\29")
|
||||||
|
|
||||||
|
if "@" in filter_login:
|
||||||
|
ldap_filter = "(|{}{})".format("(mail={})".format(filter_login), "(sAMAccountName={})".format(filter_login))
|
||||||
|
else:
|
||||||
|
ldap_filter = "(|{}{})".format("(sAMAccountName={})".format(filter_login), "(mail={})".format(filter_login))
|
||||||
|
|
||||||
|
entries = ad_search_all_bases(ldap_filter, ["cn", "sAMAccountName", "memberOf"])
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
return None
|
||||||
|
|
||||||
|
entry = entries[0]
|
||||||
|
return {
|
||||||
|
"dn": entry.entry_dn,
|
||||||
|
"cn": str(entry.cn),
|
||||||
|
"sam": str(entry.sAMAccountName),
|
||||||
|
"groups": [str(g) for g in entry.memberOf],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_2fa_group(login: str) -> dict:
|
||||||
|
"""Проверить, входит ли пользователь в группу 2FA."""
|
||||||
|
user = find_user(login)
|
||||||
|
if user is None:
|
||||||
|
return {"found": False, "error": f"Пользователь '{login}' не найден в AD."}
|
||||||
|
|
||||||
|
# Ищем группу, содержащую "2FA" в имени
|
||||||
|
groups_2fa = [g for g in user["groups"] if "2FA" in g.upper()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"found": True,
|
||||||
|
"user": user,
|
||||||
|
"in_2fa_group": len(groups_2fa) > 0,
|
||||||
|
"matching_groups": groups_2fa,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(" Проверка пользователя в группе 2FA (Active Directory)")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"\n Сервер AD: {AD_SERVER}")
|
||||||
|
print(f" Базы поиска: {config.AD_BASES}")
|
||||||
|
print(f" Подключено как: {AD_USER}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
login = input(" Введите логин (sAMAccountName или email) или 'q' для выхода:\n > ").strip()
|
||||||
|
if not login or login.lower() == "q":
|
||||||
|
print("\n До свидания!\n")
|
||||||
|
break
|
||||||
|
|
||||||
|
result = check_2fa_group(login)
|
||||||
|
|
||||||
|
if not result["found"]:
|
||||||
|
print(f"\n ❌ {result['error']}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
u = result["user"]
|
||||||
|
print(f"\n ✅ Пользователь найден:")
|
||||||
|
print(f" CN: {u['cn']}")
|
||||||
|
print(f" sAM: {u['sam']}")
|
||||||
|
print(f" DN: {u['dn']}")
|
||||||
|
print(f" Групп: {len(u['groups'])}")
|
||||||
|
|
||||||
|
if result["in_2fa_group"]:
|
||||||
|
print(f"\n 🟢 Пользователь ВХОДИТ в группу 2FA:")
|
||||||
|
for g in result["matching_groups"]:
|
||||||
|
print(f" {g}")
|
||||||
|
else:
|
||||||
|
print(f"\n 🔴 Пользователь НЕ найден в группе 2FA")
|
||||||
|
if u["groups"]:
|
||||||
|
print(f"\n Все группы пользователя ({len(u['groups'])}):")
|
||||||
|
for g in u["groups"]:
|
||||||
|
print(f" {g}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n Прервано пользователем.\n")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n ❌ Ошибка: {e}")
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Проверочный скрипт: user_id -> CARD_ID -> телефон.
|
||||||
|
|
||||||
|
Интерактивный режим: после каждого запроса спрашивает,
|
||||||
|
хочешь ли продолжить.
|
||||||
|
|
||||||
|
config.py сам подтягивает все секреты из Passwork при импорте.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# config.py при импорте заполняет globals():
|
||||||
|
# AD_USER, AD_PASSWORD, AD_SERVER, AD_BASES
|
||||||
|
# DB_USER, DB_PASSWORD, SQL_SERVER, SQL_DB_NAME
|
||||||
|
# ============================================================
|
||||||
|
sys.path.insert(0, "/opt/trueconf_bot")
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# AD: user_id -> CARD_ID
|
||||||
|
# ============================================================
|
||||||
|
def get_card_id_from_ad(user_id: str) -> str:
|
||||||
|
"""Ищем пользователя в AD по всем OU из AD_BASES, возвращаем extensionAttribute2 (CARD_ID)."""
|
||||||
|
from utils.ad_search import search_by_user_id
|
||||||
|
|
||||||
|
entries = search_by_user_id(user_id, ["extensionAttribute2"])
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
raise Exception("Пользователь не найден в AD")
|
||||||
|
|
||||||
|
entry = entries[0]
|
||||||
|
|
||||||
|
if "extensionAttribute2" in entry and entry.extensionAttribute2.value:
|
||||||
|
card_id = str(entry.extensionAttribute2.value).strip()
|
||||||
|
log.info("CARD_ID: {}".format(card_id))
|
||||||
|
return card_id
|
||||||
|
|
||||||
|
raise Exception("extensionAttribute2 пуст для этого пользователя в AD")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SQL: CARD_ID -> телефон в PR_CARD
|
||||||
|
# ============================================================
|
||||||
|
def get_phone_from_db(card_id: str) -> str:
|
||||||
|
"""Ищем сотрудника в UOV_SELFSERVICE_PR_CARD по CARD_ID."""
|
||||||
|
import pymssql
|
||||||
|
|
||||||
|
conn = pymssql.connect(
|
||||||
|
server=config.SQL_SERVER,
|
||||||
|
user=config.DB_USER,
|
||||||
|
password=config.DB_PASSWORD,
|
||||||
|
database=config.SQL_DB_NAME,
|
||||||
|
charset="cp1251",
|
||||||
|
)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT c.ID, c.PHONE, c.F_NAME, c.L_NAME, c.M_NAME
|
||||||
|
FROM UOV_SELFSERVICE_PR_CARD c
|
||||||
|
WHERE c.ID = %s
|
||||||
|
"""
|
||||||
|
cursor.execute(query, (card_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise Exception("CARD_ID {} не найден в PR_CARD".format(card_id))
|
||||||
|
|
||||||
|
card_id_db = row[0]
|
||||||
|
phone = str(row[1]) if row[1] else "Не указан"
|
||||||
|
f_name = str(row[2]) if row[2] else ""
|
||||||
|
l_name = str(row[3]) if row[3] else ""
|
||||||
|
m_name = str(row[4]) if row[4] else ""
|
||||||
|
|
||||||
|
log.info("PR_CARD.ID: {}".format(card_id_db))
|
||||||
|
log.info("ФИО: {} {} {}".format(l_name, f_name, m_name))
|
||||||
|
log.info("PHONE: {}".format(phone))
|
||||||
|
return phone
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# MAIN
|
||||||
|
# ============================================================
|
||||||
|
def run_one():
|
||||||
|
"""Выполнить один запрос: ввод user_id -> CARD_ID -> телефон."""
|
||||||
|
user_id = input("\nВведите user_id (email или sAMAccountName): ").strip()
|
||||||
|
if not user_id:
|
||||||
|
print("user_id пустой, выход.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("[1] Поиск CARD_ID в Active Directory")
|
||||||
|
print(" Сервер: {}".format(config.AD_SERVER))
|
||||||
|
print(" Базы поиска: {}".format(config.AD_BASES))
|
||||||
|
print(" user_id: {}".format(user_id))
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
card_id = get_card_id_from_ad(user_id)
|
||||||
|
except Exception as e:
|
||||||
|
print("Ошибка AD: {}".format(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("[2] Поиск телефона в базе BossCopy")
|
||||||
|
print(" Сервер: {}".format(config.SQL_SERVER))
|
||||||
|
print(" CARD_ID: {}".format(card_id))
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
phone = get_phone_from_db(card_id)
|
||||||
|
except Exception as e:
|
||||||
|
print("Ошибка SQL: {}".format(e))
|
||||||
|
return card_id
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(" ИТОГ")
|
||||||
|
print("=" * 70)
|
||||||
|
print(" user_id: {}".format(user_id))
|
||||||
|
print(" CARD_ID: {}".format(card_id))
|
||||||
|
print(" Телефон: {}".format(phone))
|
||||||
|
print("=" * 70)
|
||||||
|
print()
|
||||||
|
|
||||||
|
return card_id
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(" Проверка: user_id -> CARD_ID -> телефон")
|
||||||
|
print("=" * 70)
|
||||||
|
print()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
run_one()
|
||||||
|
|
||||||
|
again = input("Продолжить? (y/n): ").strip().lower()
|
||||||
|
if again not in ("y", "yes", "д", "да"):
|
||||||
|
print("Готово.")
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
Binary file not shown.
+26
-2
@@ -85,8 +85,21 @@ ENABLE_SERVICE_DESK = True # Модуль создания заявок в
|
|||||||
ENABLE_PHOTO_BOT = True # Модуль обработки фото/сканов
|
ENABLE_PHOTO_BOT = True # Модуль обработки фото/сканов
|
||||||
ENABLE_TRANSCRIPTION = True # Модуль расшифровки голосовых сообщений (Whisper)
|
ENABLE_TRANSCRIPTION = True # Модуль расшифровки голосовых сообщений (Whisper)
|
||||||
ENABLE_SEARCH_BOT = True # Модуль интеллектуального поиска по регламентам (RAG)
|
ENABLE_SEARCH_BOT = True # Модуль интеллектуального поиска по регламентам (RAG)
|
||||||
|
ENABLE_INSTRUCT = True # Модуль инструкций (Trueconf, Почта)
|
||||||
ENABLE_LK = True # Личный кабинет сотрудника (отпуска, расчетные листки, справки)
|
ENABLE_LK = True # Личный кабинет сотрудника (отпуска, расчетные листки, справки)
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 1.1 ТЕСТОВЫЙ ПОД-РЕЖИМ ФОТО-БОТА («1 — тест проверка фото»)
|
||||||
|
# При включённом флаге в меню «Режим обновления фото профиля»
|
||||||
|
# появляется пункт «1 — тест проверка фото». Внутри под-режима
|
||||||
|
# фото уходит НАПРЯМУЮ в ИИ (llama.cpp, тот же endpoint и промпт,
|
||||||
|
# что в SibCemProfile/PhotoApp/photo_validator.py), результат
|
||||||
|
# показывается пользователю. Само фото никуда НЕ ДОСЫЛАЕТСЯ
|
||||||
|
# (ни в AD, ни в API, ни на модерацию). Штатный поток
|
||||||
|
# (фото → превью → публикация) работает параллельно.
|
||||||
|
# =========================================================
|
||||||
|
PHOTO_TEST_MODE = False # True — в меню фото-режима доступен пункт «тест проверка фото»
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# 2. КОНТРОЛЬ ДОСТУПА
|
# 2. КОНТРОЛЬ ДОСТУПА
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -94,7 +107,9 @@ ENABLE_WHITELIST = True # Включить режим закрытого
|
|||||||
ALLOWED_USERS = [ # Список TrueConf ID разрешенных пользователей
|
ALLOWED_USERS = [ # Список TrueConf ID разрешенных пользователей
|
||||||
"ds.krivochenko@tcs.sibcem.ru",
|
"ds.krivochenko@tcs.sibcem.ru",
|
||||||
"mm.norenberg@tcs.sibcem.ru",
|
"mm.norenberg@tcs.sibcem.ru",
|
||||||
"a.kirpichev@tcs.sibcem.ru"
|
"a.kirpichev@tcs.sibcem.ru",
|
||||||
|
"bot_test@tcs.sibcem.ru",
|
||||||
|
"v.s.sholohov@tcs.sibcem.ru"
|
||||||
]
|
]
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -103,7 +118,10 @@ ALLOWED_USERS = [ # Список TrueConf ID разрешенны
|
|||||||
TC_SERVER = os.getenv("TC_SERVER", "") # Адрес сервера TrueConf
|
TC_SERVER = os.getenv("TC_SERVER", "") # Адрес сервера TrueConf
|
||||||
|
|
||||||
AD_SERVER = "ldap://172.16.20.20" # Адрес контроллера домена Active Directory
|
AD_SERVER = "ldap://172.16.20.20" # Адрес контроллера домена Active Directory
|
||||||
AD_BASE = "OU=-Пользователи,DC=sibcem,DC=ru" # Базовый путь поиска пользователей в AD
|
AD_BASES = ( # Базовые пути поиска пользователей в AD (несколько OU)
|
||||||
|
"OU=-Пользователи,DC=sibcem,DC=ru",
|
||||||
|
"OU=Планшеты,OU=enabled,OU=БезКомпьютеров,DC=sibcem,DC=ru",
|
||||||
|
)
|
||||||
|
|
||||||
SQL_SERVER = "SRVKEM-MOBILEIN.sibcem.ru" # Сервер MS SQL для работы Личного кабинета
|
SQL_SERVER = "SRVKEM-MOBILEIN.sibcem.ru" # Сервер MS SQL для работы Личного кабинета
|
||||||
SQL_DB_NAME = "BossCopy" # База данных с информацией по кадрам и ЗП
|
SQL_DB_NAME = "BossCopy" # База данных с информацией по кадрам и ЗП
|
||||||
@@ -217,3 +235,9 @@ OTP_MAX_ATTEMPTS = 3
|
|||||||
OTP_BLOCK_DURATION_SEC = 1800
|
OTP_BLOCK_DURATION_SEC = 1800
|
||||||
# Глобальный таймаут неактивности пользователя в любом подменю (1800 секунд = 30 минут)
|
# Глобальный таймаут неактивности пользователя в любом подменю (1800 секунд = 30 минут)
|
||||||
SESSION_TIMEOUT = 1800
|
SESSION_TIMEOUT = 1800
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 11. МОДУЛЬ INSTRUCT: НАСТРОЙКИ SERVICE DESK: ГРУППА И ИСПОЛНИТЕЛЬ
|
||||||
|
# =========================================================
|
||||||
|
SD_INSTRUCT_GROUP_NAME = "Техподдержка ХК" # Название группы в ServiceDesk
|
||||||
|
SD_INSTRUCT_TECHNICIAN_NAME = "Кривоченко Денис Сергеевич" # ФИО техника в ServiceDesk
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Debug: print the exact LDAP filter that works in lk/handlers.py"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "/opt/trueconf_bot")
|
||||||
|
from config.config import AD_USER, AD_PASSWORD, AD_SERVER, AD_BASES
|
||||||
|
from utils.ad_search import search_by_filter as ad_search_all_bases
|
||||||
|
|
||||||
|
from ldap3.utils.conv import escape_filter_chars
|
||||||
|
|
||||||
|
# Simulate lk/handlers.py filter
|
||||||
|
user_id = "ds.krivochenko@tcs.sibcem.ru"
|
||||||
|
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
|
||||||
|
short_username = search_id.split("@")[0]
|
||||||
|
safe_user_id = escape_filter_chars(search_id)
|
||||||
|
|
||||||
|
print(f"user_id: {user_id}")
|
||||||
|
print(f"search_id: {search_id}")
|
||||||
|
print(f"short_username: {short_username}")
|
||||||
|
print(f"safe_user_id: {safe_user_id}")
|
||||||
|
|
||||||
|
# lk/handlers.py filter
|
||||||
|
filter_lk = "(&(objectClass=user)(|(mail={})(userPrincipalName={})(userPrincipalName={}{}{}))({}))".format(
|
||||||
|
safe_user_id,
|
||||||
|
safe_user_id,
|
||||||
|
escape_filter_chars(short_username),
|
||||||
|
"@sibcem.ru",
|
||||||
|
"",
|
||||||
|
escape_filter_chars(short_username),
|
||||||
|
)
|
||||||
|
print(f"\nFilter (lk format):")
|
||||||
|
print(filter_lk)
|
||||||
|
|
||||||
|
# Count parens
|
||||||
|
opens = filter_lk.count('(')
|
||||||
|
closes = filter_lk.count(')')
|
||||||
|
print(f"\nOpen parens: {opens}, Close parens: {closes}")
|
||||||
|
|
||||||
|
# Now test with the connection
|
||||||
|
print(f"\nSearching with filter across all AD_BASES...")
|
||||||
|
entries = ad_search_all_bases(filter_lk, ["cn", "sAMAccountName", "mail", "userPrincipalName"])
|
||||||
|
|
||||||
|
if entries:
|
||||||
|
entry = entries[0]
|
||||||
|
print(f"FOUND: cn={entry.cn}, mail={entry.mail if 'mail' in entry else 'N/A'}, sam={entry.sAMAccountName if 'sAMAccountName' in entry else 'N/A'}")
|
||||||
|
else:
|
||||||
|
print("NOT FOUND in any AD_BASE")
|
||||||
|
|
||||||
|
# Try root
|
||||||
|
root_base = "DC=sibcem,DC=ru"
|
||||||
|
print(f"\nSearching in {root_base}...")
|
||||||
|
from utils.ad_search import search_by_filter
|
||||||
|
entries2 = search_by_filter(filter_lk, ["cn", "sAMAccountName", "mail", "userPrincipalName"])
|
||||||
|
|
||||||
|
if entries2:
|
||||||
|
entry = entries2[0]
|
||||||
|
print(f"FOUND: cn={entry.cn}, mail={entry.mail if 'mail' in entry else 'N/A'}, sam={entry.sAMAccountName if 'sAMAccountName' in entry else 'N/A'}")
|
||||||
|
else:
|
||||||
|
print("NOT FOUND in root")
|
||||||
Binary file not shown.
@@ -0,0 +1,557 @@
|
|||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
|
||||||
|
from trueconf import Router, Message
|
||||||
|
from trueconf.types import FSInputFile
|
||||||
|
|
||||||
|
from utils.texts import (
|
||||||
|
UNKNOWN_MAIN_CMD_TEXT,
|
||||||
|
EMOJI_DIGITS,
|
||||||
|
INSTRUCT_MAIN_MENU_TEXT,
|
||||||
|
INSTRUCT_TRUECONF_TEXT,
|
||||||
|
INSTRUCT_TRUECONF_WITH_AD,
|
||||||
|
INSTRUCT_TRUECONF_PENDING_TEXT,
|
||||||
|
INSTRUCT_EMAIL_AVAILABLE,
|
||||||
|
INSTRUCT_EMAIL_HISTORY_ASK,
|
||||||
|
INSTRUCT_EMAIL_NOT_HAD,
|
||||||
|
INSTRUCT_EMAIL_UNAVAILABLE,
|
||||||
|
INSTRUCT_ERROR_MSG,
|
||||||
|
)
|
||||||
|
from utils.states import get_state, set_state, clear_state
|
||||||
|
from utils.stats_logger import log_menu_stats
|
||||||
|
|
||||||
|
# Безопасный импорт конфигурации
|
||||||
|
try:
|
||||||
|
from config.config import (
|
||||||
|
DEFAULT_REQUESTER, SD_URL, SD_TOKEN,
|
||||||
|
SD_INSTRUCT_GROUP_NAME, SD_INSTRUCT_TECHNICIAN_NAME,
|
||||||
|
SMTP_SERVER, SMTP_PORT, DEFAULT_EMAIL_FROM, ALERTS_SUPPORT_EMAILS
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
import importlib.util
|
||||||
|
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "config.py")
|
||||||
|
spec = importlib.util.spec_from_file_location("custom_config", config_path)
|
||||||
|
custom_config = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(custom_config)
|
||||||
|
DEFAULT_REQUESTER = custom_config.DEFAULT_REQUESTER
|
||||||
|
SD_URL = custom_config.SD_URL
|
||||||
|
SD_TOKEN = custom_config.SD_TOKEN
|
||||||
|
SD_INSTRUCT_GROUP_NAME = custom_config.SD_INSTRUCT_GROUP_NAME
|
||||||
|
SD_INSTRUCT_TECHNICIAN_NAME = custom_config.SD_INSTRUCT_TECHNICIAN_NAME
|
||||||
|
SMTP_SERVER = getattr(custom_config, "SMTP_SERVER", "srvkem-mail.sibcem.ru")
|
||||||
|
SMTP_PORT = getattr(custom_config, "SMTP_PORT", 25)
|
||||||
|
DEFAULT_EMAIL_FROM = getattr(custom_config, "DEFAULT_EMAIL_FROM", "ai@sibcem.ru")
|
||||||
|
ALERTS_SUPPORT_EMAILS = getattr(custom_config, "ALERTS_SUPPORT_EMAILS", ["ds.krivochenko@sibcem.ru"])
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
TRUECONF_INSTRUCT_PATH = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
"Проверка_наличия_и_авторизация_на_мобильном_устройстве_Trueconf.docx"
|
||||||
|
)
|
||||||
|
|
||||||
|
GMAIL_ANDROID_INSTRUCT_PATH = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
"Инструкция по настройке электронной почты GMAIL на Android.docx"
|
||||||
|
)
|
||||||
|
|
||||||
|
APPLE_INSTRUCT_PATH = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
"Инструкция по настройке электронной почты на устройствах Apple.docx"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ВСПОМОГАТЕЛЬНАЯ ФУНКЦИЯ ОТПРАВКИ EMAIL УВЕДОМЛЕНИЙ
|
||||||
|
|
||||||
|
def _send_email_sync(to_emails: list[str], subject: str, body_html: str):
|
||||||
|
"""Синхронная отправка письма по SMTP"""
|
||||||
|
try:
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = DEFAULT_EMAIL_FROM
|
||||||
|
msg["To"] = ", ".join(to_emails)
|
||||||
|
|
||||||
|
part_html = MIMEText(body_html, "html", "utf-8")
|
||||||
|
msg.attach(part_html)
|
||||||
|
|
||||||
|
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=10) as server:
|
||||||
|
server.sendmail(DEFAULT_EMAIL_FROM, to_emails, msg.as_string())
|
||||||
|
logger.info(f"📧 Уведомление о заявке 2FA успешно отправлено на {to_emails}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка отправки почтового уведомления 2FA: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_2fa_alert_email(cn: str, user_id: str, ticket_id: str):
|
||||||
|
"""Фоновая асинхронная задача формирования и отправки уведомления"""
|
||||||
|
if not ALERTS_SUPPORT_EMAILS:
|
||||||
|
logger.warning("ALERTS_SUPPORT_EMAILS пуст. Уведомление на почту пропущено.")
|
||||||
|
return
|
||||||
|
|
||||||
|
clean_user_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
|
||||||
|
subject = f"🎫 [2FA Request] Создана заявка #{ticket_id} на доступ к Trueconf ({cn})"
|
||||||
|
|
||||||
|
ticket_link = f"{SD_URL.rstrip('/')}/WorkOrder.do?woMode=viewWO&woID={ticket_id}"
|
||||||
|
|
||||||
|
body_html = f"""
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; color: #333;">
|
||||||
|
<h2>🎫 Создана новая заявка на доступ к Trueconf (2FA)</h2>
|
||||||
|
<p><b>Сотрудник:</b> {cn}</p>
|
||||||
|
<p><b>Логин / Email:</b> {clean_user_id}</p>
|
||||||
|
<p><b>Номер заявки в ServiceDesk:</b> <a href="{ticket_link}">#{ticket_id}</a></p>
|
||||||
|
<p><b>Назначенная группа:</b> {SD_INSTRUCT_GROUP_NAME}</p>
|
||||||
|
<p><b>Исполнитель:</b> {SD_INSTRUCT_TECHNICIAN_NAME}</p>
|
||||||
|
<hr style="border: 0; border-top: 1px solid #ccc;">
|
||||||
|
<p style="font-size: 12px; color: #777;"><i>Сообщение сгенерировано автоматически цифровым ассистентом КЛЕВЕР.</i></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
await asyncio.to_thread(_send_email_sync, ALERTS_SUPPORT_EMAILS, subject, body_html)
|
||||||
|
|
||||||
|
|
||||||
|
# ВСПОМОГАТЕЛЬНАЯ ФУНКЦИЯ ПРОВЕРКИ ЗАКРЫТОГО СТАТУСА ЗАЯВКИ
|
||||||
|
|
||||||
|
def _is_ticket_closed(status_data) -> bool:
|
||||||
|
"""
|
||||||
|
Проверяет, является ли статус заявки закрытым / исполненным.
|
||||||
|
Использует поиск по основам слов для улавливания всех форматов и склонений.
|
||||||
|
"""
|
||||||
|
if not status_data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if isinstance(status_data, dict):
|
||||||
|
status_str = str(status_data.get("name", "")).strip().lower()
|
||||||
|
else:
|
||||||
|
status_str = str(status_data).strip().lower()
|
||||||
|
|
||||||
|
closed_keywords = [
|
||||||
|
"closed", "close", "resolved", "resolv", "solved", "solv",
|
||||||
|
"rejected", "reject", "canceled", "cancel", "completed", "complete",
|
||||||
|
"закрыт", "решен", "исполн", "выполн", "отмен", "отклон", "заверш"
|
||||||
|
]
|
||||||
|
|
||||||
|
return any(kw in status_str for kw in closed_keywords)
|
||||||
|
|
||||||
|
|
||||||
|
# ОСНОВНАЯ ЛОГИКА АКТИВНОСТИ
|
||||||
|
|
||||||
|
def get_user_cn(user_id: str) -> str | None:
|
||||||
|
"""Получить CN пользователя из AD по его user_id."""
|
||||||
|
from utils.ad_search import search_by_user_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
|
||||||
|
short_username = search_id.split("@")[0] if "@" in search_id else user_id
|
||||||
|
|
||||||
|
logger.info(f"AD lookup CN: user_id={user_id}, search_id={search_id}, short={short_username}")
|
||||||
|
|
||||||
|
entries = search_by_user_id(search_id, ["cn", "sAMAccountName", "mail", "userPrincipalName", "l", "userAccountControl"])
|
||||||
|
|
||||||
|
if entries:
|
||||||
|
entry = entries[0]
|
||||||
|
cn = str(entry.cn)
|
||||||
|
mail = str(entry.mail) if 'mail' in entry else ""
|
||||||
|
sam = str(entry.sAMAccountName) if 'sAMAccountName' in entry else "N/A"
|
||||||
|
upn = str(entry.userPrincipalName) if 'userPrincipalName' in entry else "N/A"
|
||||||
|
logger.info(f"AD found: cn={cn}, mail={mail}, sam={sam}, upn={upn}")
|
||||||
|
return cn
|
||||||
|
|
||||||
|
logger.warning(f"AD not found for user_id={user_id}, search_id={search_id}, short={short_username}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка получения CN из AD для {user_id}: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_ad_user_info(user_id: str) -> dict | None:
|
||||||
|
"""Получить полную информацию о пользователе из AD."""
|
||||||
|
from utils.ad_search import search_by_user_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
|
||||||
|
entries = search_by_user_id(search_id, ["cn", "mail", "l", "userAccountControl", "userPrincipalName"])
|
||||||
|
|
||||||
|
if entries:
|
||||||
|
entry = entries[0]
|
||||||
|
cn = str(entry.cn)
|
||||||
|
raw_mail = str(entry.mail) if 'mail' in entry else ""
|
||||||
|
if raw_mail in ("[]", "N/A", ""):
|
||||||
|
raw_mail = None
|
||||||
|
mail = raw_mail if raw_mail else None
|
||||||
|
if not mail:
|
||||||
|
raw_upn = str(entry.userPrincipalName) if 'userPrincipalName' in entry else ""
|
||||||
|
if raw_upn and raw_upn not in ("[]", "N/A", ""):
|
||||||
|
mail = raw_upn
|
||||||
|
city = str(entry.l) if 'l' in entry and entry.l.value else "Кемерово"
|
||||||
|
return {"cn": cn, "mail": mail, "raw_mail": raw_mail, "city": city}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка получения AD info для {user_id}: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_sd_api_request(
|
||||||
|
requester_cn: str,
|
||||||
|
requester_email: str,
|
||||||
|
subject: str,
|
||||||
|
description: str,
|
||||||
|
city: str
|
||||||
|
) -> str | None:
|
||||||
|
"""Автономная прямая отправка HTTP-запроса в Service Desk API v3."""
|
||||||
|
if requester_cn and requester_cn != "N/A":
|
||||||
|
requester_payload = {"name": requester_cn}
|
||||||
|
elif requester_email and requester_email not in (DEFAULT_REQUESTER, "ai@sibcem.ru"):
|
||||||
|
requester_payload = {"email_id": requester_email}
|
||||||
|
else:
|
||||||
|
requester_payload = {"name": "Искусственный Интеллект"}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"request": {
|
||||||
|
"subject": subject,
|
||||||
|
"description": description,
|
||||||
|
"requester": requester_payload,
|
||||||
|
"udf_fields": {
|
||||||
|
"udf_pick_301": city
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if SD_INSTRUCT_GROUP_NAME:
|
||||||
|
payload["request"]["group"] = {"name": SD_INSTRUCT_GROUP_NAME}
|
||||||
|
|
||||||
|
if SD_INSTRUCT_TECHNICIAN_NAME:
|
||||||
|
payload["request"]["technician"] = {"name": SD_INSTRUCT_TECHNICIAN_NAME}
|
||||||
|
|
||||||
|
url = f"{SD_URL.rstrip('/')}/api/v3/requests"
|
||||||
|
headers = {"TECHNICIAN_KEY": SD_TOKEN}
|
||||||
|
data = {"input_data": json.dumps(payload)}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(verify=False) as client:
|
||||||
|
response = await client.post(url, headers=headers, data=data, timeout=15.0)
|
||||||
|
|
||||||
|
if response.status_code in (200, 201):
|
||||||
|
res_json = response.json()
|
||||||
|
ticket_id = res_json.get("request", {}).get("id")
|
||||||
|
return str(ticket_id) if ticket_id else None
|
||||||
|
|
||||||
|
elif response.status_code == 400 and requester_payload != {"name": "Искусственный Интеллект"}:
|
||||||
|
logger.warning(
|
||||||
|
f"ServiceDesk не смог привязать requester={requester_payload}. "
|
||||||
|
f"Повторяем запрос от имени системного заявителя..."
|
||||||
|
)
|
||||||
|
payload["request"]["requester"] = {"name": "Искусственный Интеллект"}
|
||||||
|
payload["request"]["description"] = f"[Пользователь: {requester_cn}]\n\n{description}"
|
||||||
|
data_fb = {"input_data": json.dumps(payload)}
|
||||||
|
|
||||||
|
resp_fb = await client.post(url, headers=headers, data=data_fb, timeout=15.0)
|
||||||
|
if resp_fb.status_code in (200, 201):
|
||||||
|
ticket_id = resp_fb.json().get("request", {}).get("id")
|
||||||
|
return str(ticket_id) if ticket_id else None
|
||||||
|
|
||||||
|
logger.error(f"Service Desk API Error ({response.status_code}): {response.text}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_existing_trueconf_ticket(cn: str, requester_email: str = None) -> str | None:
|
||||||
|
"""Находит открытую или находящуюся в работе заявку по Trueconf."""
|
||||||
|
subject_keyword = "запрос доступа к trueconf"
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"{SD_URL.rstrip('/')}/api/v3/requests"
|
||||||
|
headers = {"TECHNICIAN_KEY": SD_TOKEN}
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
"list_info": {
|
||||||
|
"row_count": 100,
|
||||||
|
"sort_field": "created_time",
|
||||||
|
"sort_order": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(verify=False) as client:
|
||||||
|
resp = await client.get(url, headers=headers, params={"input_data": json.dumps(input_data)}, timeout=10.0)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warning(f"SD GET list_info returned status {resp.status_code}. Retrying pure GET...")
|
||||||
|
resp = await client.get(url, headers=headers, timeout=10.0)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
requests_list = data.get("requests", [])
|
||||||
|
logger.info(f"Scanning {len(requests_list)} recent tickets for CN='{cn}', Email='{requester_email}'")
|
||||||
|
|
||||||
|
for req in requests_list:
|
||||||
|
req_subject = str(req.get("subject", "")).lower()
|
||||||
|
|
||||||
|
if subject_keyword not in req_subject:
|
||||||
|
continue
|
||||||
|
|
||||||
|
req_id = req.get("id")
|
||||||
|
req_status = req.get("status")
|
||||||
|
|
||||||
|
# 1. Проверяем статус в общем списке
|
||||||
|
if _is_ticket_closed(req_status):
|
||||||
|
logger.info(f"Skipping closed ticket #{req_id} (list status: '{req_status}')")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Checking candidate open ticket #{req_id}")
|
||||||
|
|
||||||
|
detail_url = f"{SD_URL.rstrip('/')}/api/v3/requests/{req_id}"
|
||||||
|
detail_resp = await client.get(detail_url, headers=headers, timeout=10.0)
|
||||||
|
|
||||||
|
if detail_resp.status_code == 200:
|
||||||
|
detail = detail_resp.json().get("request", {})
|
||||||
|
|
||||||
|
# 2. Повторно проверяем статус в деталях заявки
|
||||||
|
detail_status = detail.get("status")
|
||||||
|
if _is_ticket_closed(detail_status):
|
||||||
|
logger.info(f"Skipping closed ticket #{req_id} (detail status: '{detail_status}')")
|
||||||
|
continue
|
||||||
|
|
||||||
|
requester = detail.get("requester", {})
|
||||||
|
req_name = str(requester.get("name", "") or requester.get("display_name", "")).strip().lower()
|
||||||
|
req_email = str(requester.get("email", "") or requester.get("email_id", "")).strip().lower()
|
||||||
|
req_desc = str(detail.get("description", "") or "").lower()
|
||||||
|
|
||||||
|
cn_clean = cn.strip().lower()
|
||||||
|
|
||||||
|
if cn_clean and cn_clean in req_name:
|
||||||
|
logger.info(f"Matched ticket #{req_id} by requester name ('{req_name}')")
|
||||||
|
return str(req_id)
|
||||||
|
|
||||||
|
if (
|
||||||
|
requester_email
|
||||||
|
and requester_email.lower() not in (DEFAULT_REQUESTER.lower(), "ai@sibcem.ru")
|
||||||
|
and req_email == requester_email.lower()
|
||||||
|
):
|
||||||
|
logger.info(f"Matched ticket #{req_id} by email ('{req_email}')")
|
||||||
|
return str(req_id)
|
||||||
|
|
||||||
|
if cn_clean and (f"[пользователь: {cn_clean}]" in req_desc or cn_clean in req_desc):
|
||||||
|
logger.info(f"Matched ticket #{req_id} by user mention in description")
|
||||||
|
return str(req_id)
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to fetch SD requests list completely, HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to search existing tickets: {e}", exc_info=True)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_sd_ticket_for_trueconf(user_id: str, cn: str) -> str | None:
|
||||||
|
"""Создать заявку в Service Desk для запроса доступа к Trueconf."""
|
||||||
|
subject = "Запрос доступа к Trueconf"
|
||||||
|
|
||||||
|
clean_user_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
|
||||||
|
|
||||||
|
ad_info = get_ad_user_info(user_id)
|
||||||
|
requester_email = (ad_info.get("mail") if ad_info else None) or DEFAULT_REQUESTER
|
||||||
|
city = ad_info.get("city", "Кемерово") if ad_info else "Кемерово"
|
||||||
|
|
||||||
|
# Защита от дублей
|
||||||
|
existing_ticket = await _find_existing_trueconf_ticket(cn, requester_email)
|
||||||
|
if existing_ticket:
|
||||||
|
logger.info(f"Ticket creation skipped: open ticket #{existing_ticket} already exists for {cn}")
|
||||||
|
return existing_ticket
|
||||||
|
|
||||||
|
description = (
|
||||||
|
f"Пользователь {cn} ({clean_user_id}) запросил инструкцию по Trueconf, "
|
||||||
|
f"но не имеет группы 2FA. Требуется выдача доступа."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Calling SD API directly: cn='{cn}', email='{requester_email}', city='{city}'")
|
||||||
|
ticket_id = await _send_sd_api_request(cn, requester_email, subject, description, city)
|
||||||
|
if ticket_id:
|
||||||
|
logger.info(f"SD ticket created for {cn}: #{ticket_id}")
|
||||||
|
# 📧 ОТПРАВЛЯЕМ ПОЧТОВОЕ УВЕДОМЛЕНИЕ В ФОНЕ
|
||||||
|
asyncio.create_task(send_2fa_alert_email(cn, user_id, ticket_id))
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to create SD ticket for {cn}")
|
||||||
|
return ticket_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка создания SD заявки для {cn}: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.message()
|
||||||
|
async def instruct_router_handler(msg: Message):
|
||||||
|
user_id = msg.from_user.id
|
||||||
|
user_name = getattr(msg.from_user, 'name', 'N/A')
|
||||||
|
user_username = getattr(msg.from_user, 'username', 'N/A')
|
||||||
|
|
||||||
|
logger.info(f"Instruct: user_id={user_id}, name={user_name}, username={user_username}")
|
||||||
|
|
||||||
|
current_state = get_state(user_id)
|
||||||
|
|
||||||
|
if not msg.text:
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd = msg.text.strip().lower()
|
||||||
|
|
||||||
|
for raw_num, emoji_num in EMOJI_DIGITS.items():
|
||||||
|
if cmd == emoji_num:
|
||||||
|
cmd = raw_num
|
||||||
|
break
|
||||||
|
|
||||||
|
if current_state is None:
|
||||||
|
from utils.menu import MENU_MAP
|
||||||
|
if MENU_MAP.get(cmd) == "INSTRUCT":
|
||||||
|
msg.handled = True
|
||||||
|
set_state(user_id, "INSTRUCT_MODE")
|
||||||
|
await msg.answer(INSTRUCT_MAIN_MENU_TEXT, parse_mode="html")
|
||||||
|
return
|
||||||
|
return
|
||||||
|
|
||||||
|
if current_state == "INSTRUCT_MODE":
|
||||||
|
msg.handled = True
|
||||||
|
if cmd == "0":
|
||||||
|
log_menu_stats(user_id, "Инструкции", "Выход в главное меню")
|
||||||
|
clear_state(user_id)
|
||||||
|
from utils.menu import MENU_TEXT
|
||||||
|
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||||
|
elif cmd == "1":
|
||||||
|
log_menu_stats(user_id, "Инструкции", "Trueconf")
|
||||||
|
cn = get_user_cn(user_id)
|
||||||
|
if cn is None:
|
||||||
|
cn = user_id
|
||||||
|
|
||||||
|
from utils.ad_checker import check_group_membership
|
||||||
|
ad_check = check_group_membership(cn, "2FA")
|
||||||
|
|
||||||
|
if ad_check.get("in_group", False):
|
||||||
|
set_state(user_id, "INSTRUCT_TRUECONF_VIEW")
|
||||||
|
text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn, None)
|
||||||
|
if os.path.isfile(TRUECONF_INSTRUCT_PATH):
|
||||||
|
await msg.bot.send_document(
|
||||||
|
chat_id=msg.chat_id,
|
||||||
|
file=FSInputFile(TRUECONF_INSTRUCT_PATH, file_name="Инструкция.docx"),
|
||||||
|
caption=text,
|
||||||
|
parse_mode="html"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
|
else:
|
||||||
|
ad_info = get_ad_user_info(user_id)
|
||||||
|
requester_email = (ad_info.get("mail") if ad_info else None) or DEFAULT_REQUESTER
|
||||||
|
logger.info(f"[DEBUG] Checking existing ticket for cn={cn}, email={requester_email}")
|
||||||
|
existing_ticket = await _find_existing_trueconf_ticket(cn, requester_email)
|
||||||
|
logger.info(f"[DEBUG] existing_ticket result: {existing_ticket}")
|
||||||
|
if existing_ticket:
|
||||||
|
set_state(user_id, "INSTRUCT_TRUECONF_VIEW")
|
||||||
|
text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn, existing_ticket)
|
||||||
|
if os.path.isfile(TRUECONF_INSTRUCT_PATH):
|
||||||
|
await msg.bot.send_document(
|
||||||
|
chat_id=msg.chat_id,
|
||||||
|
file=FSInputFile(TRUECONF_INSTRUCT_PATH, file_name="Инструкция.docx"),
|
||||||
|
caption=text,
|
||||||
|
parse_mode="html"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
|
else:
|
||||||
|
set_state(user_id, "INSTRUCT_TRUECONF_PENDING")
|
||||||
|
text = INSTRUCT_TRUECONF_PENDING_TEXT(EMOJI_DIGITS, cn)
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
|
elif cmd == "2":
|
||||||
|
log_menu_stats(user_id, "Инструкции", "Почта")
|
||||||
|
cn = get_user_cn(user_id)
|
||||||
|
if cn is None:
|
||||||
|
cn = user_id
|
||||||
|
ad_info = get_ad_user_info(user_id)
|
||||||
|
raw_mail = ad_info.get("raw_mail") if ad_info else None
|
||||||
|
if raw_mail:
|
||||||
|
# Пользователь с доступом к почте — спрашиваем про прошлый доступ
|
||||||
|
set_state(user_id, "INSTRUCT_EMAIL_HISTORY")
|
||||||
|
text = INSTRUCT_EMAIL_HISTORY_ASK(EMOJI_DIGITS, cn)
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
|
else:
|
||||||
|
set_state(user_id, "INSTRUCT_EMAIL_VIEW")
|
||||||
|
text = INSTRUCT_EMAIL_UNAVAILABLE(EMOJI_DIGITS, cn)
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
|
else:
|
||||||
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
|
elif current_state == "INSTRUCT_TRUECONF_PENDING":
|
||||||
|
msg.handled = True
|
||||||
|
if cmd == "0":
|
||||||
|
clear_state(user_id)
|
||||||
|
from utils.menu import MENU_TEXT
|
||||||
|
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||||
|
elif cmd == "9":
|
||||||
|
set_state(user_id, "INSTRUCT_MODE")
|
||||||
|
await msg.answer(INSTRUCT_MAIN_MENU_TEXT, parse_mode="html")
|
||||||
|
elif cmd == "1":
|
||||||
|
cn = get_user_cn(user_id)
|
||||||
|
if cn is None:
|
||||||
|
cn = user_id
|
||||||
|
|
||||||
|
ticket_id = await create_sd_ticket_for_trueconf(user_id, cn)
|
||||||
|
if ticket_id:
|
||||||
|
set_state(user_id, "INSTRUCT_TRUECONF_VIEW")
|
||||||
|
text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn, ticket_id)
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
|
else:
|
||||||
|
await msg.answer(INSTRUCT_ERROR_MSG, parse_mode="html")
|
||||||
|
else:
|
||||||
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
|
elif current_state == "INSTRUCT_EMAIL_HISTORY":
|
||||||
|
msg.handled = True
|
||||||
|
if cmd == "0":
|
||||||
|
clear_state(user_id)
|
||||||
|
from utils.menu import MENU_TEXT
|
||||||
|
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||||
|
elif cmd == "9":
|
||||||
|
set_state(user_id, "INSTRUCT_MODE")
|
||||||
|
await msg.answer(INSTRUCT_MAIN_MENU_TEXT, parse_mode="html")
|
||||||
|
elif cmd == "1":
|
||||||
|
# Да, был доступ — показываем инструкции
|
||||||
|
log_menu_stats(user_id, "Инструкции", "Почта (был доступ)")
|
||||||
|
cn = get_user_cn(user_id)
|
||||||
|
if cn is None:
|
||||||
|
cn = user_id
|
||||||
|
set_state(user_id, "INSTRUCT_EMAIL_VIEW")
|
||||||
|
text = INSTRUCT_EMAIL_AVAILABLE(EMOJI_DIGITS, cn, "")
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
|
for instruct_path, file_name in [
|
||||||
|
(GMAIL_ANDROID_INSTRUCT_PATH, "Инструкция Android.docx"),
|
||||||
|
(APPLE_INSTRUCT_PATH, "Инструкция Apple.docx"),
|
||||||
|
]:
|
||||||
|
if os.path.isfile(instruct_path):
|
||||||
|
await msg.bot.send_document(
|
||||||
|
chat_id=msg.chat_id,
|
||||||
|
file=FSInputFile(instruct_path, file_name=file_name),
|
||||||
|
)
|
||||||
|
elif cmd == "2":
|
||||||
|
# Нет, не было доступа — показываем текст про заявку
|
||||||
|
log_menu_stats(user_id, "Инструкции", "Почта (не было доступа)")
|
||||||
|
cn = get_user_cn(user_id)
|
||||||
|
if cn is None:
|
||||||
|
cn = user_id
|
||||||
|
set_state(user_id, "INSTRUCT_EMAIL_VIEW")
|
||||||
|
text = INSTRUCT_EMAIL_NOT_HAD(EMOJI_DIGITS, cn)
|
||||||
|
await msg.answer(text, parse_mode="html")
|
||||||
|
else:
|
||||||
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
|
elif current_state in ["INSTRUCT_TRUECONF_VIEW", "INSTRUCT_EMAIL_VIEW"]:
|
||||||
|
msg.handled = True
|
||||||
|
if cmd == "0":
|
||||||
|
clear_state(user_id)
|
||||||
|
from utils.menu import MENU_TEXT
|
||||||
|
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||||
|
elif cmd == "9":
|
||||||
|
set_state(user_id, "INSTRUCT_MODE")
|
||||||
|
await msg.answer(INSTRUCT_MAIN_MENU_TEXT, parse_mode="html")
|
||||||
|
else:
|
||||||
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+98
-35
@@ -57,7 +57,7 @@ EMOJI_DIGITS = {
|
|||||||
"10": "🔟"
|
"10": "🔟"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Список из 7 справок строго по структуру портала
|
# Список из 7 справок строго по структуре портала
|
||||||
DOC_TYPES = {
|
DOC_TYPES = {
|
||||||
"1": "Справка о доходах физического лица (2-НДФЛ)",
|
"1": "Справка о доходах физического лица (2-НДФЛ)",
|
||||||
"2": "Справка об удержаниях за ДМС",
|
"2": "Справка об удержаниях за ДМС",
|
||||||
@@ -74,7 +74,7 @@ LK_DOCS_MENU_TEXT = (
|
|||||||
f"\n\n<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
|
f"\n\n<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
|
||||||
)
|
)
|
||||||
|
|
||||||
USER_COOLDOWN_SEC = 1.5
|
USER_COOLDOWN_SEC = 0.5
|
||||||
EMAIL_COOLDOWN_SEC = 10.0
|
EMAIL_COOLDOWN_SEC = 10.0
|
||||||
|
|
||||||
user_last_request = {}
|
user_last_request = {}
|
||||||
@@ -122,26 +122,24 @@ def log_and_notify_error(user_id: str, action: str, error_details: str):
|
|||||||
|
|
||||||
async def handle_lk_error(msg: Message, user_id: str, action: str, error_details: str):
|
async def handle_lk_error(msg: Message, user_id: str, action: str, error_details: str):
|
||||||
await asyncio.to_thread(log_and_notify_error, user_id, action, error_details)
|
await asyncio.to_thread(log_and_notify_error, user_id, action, error_details)
|
||||||
error_msg = lk_error_handling(EMOJI_DIGITS)
|
error_msg = system_error_text(EMOJI_DIGITS)
|
||||||
await msg.answer(error_msg, parse_mode="html")
|
await msg.answer(error_msg, parse_mode="html")
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# ИНТЕГРАЦИЯ С ACTIVE DIRECTORY ЧЕРЕЗ extensionAttribute2
|
# ИНТЕГРАЦИЯ С ACTIVE DIRECTORY ЧЕРЕЗ extensionAttribute2
|
||||||
# =========================================================
|
# =========================================================
|
||||||
def get_card_id_from_ad(user_id: str) -> str:
|
def get_card_id_from_ad(user_id: str) -> str:
|
||||||
|
from utils.ad_search import search_by_user_id
|
||||||
try:
|
try:
|
||||||
server = Server(config.AD_SERVER, get_info=ALL)
|
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
|
||||||
conn = Connection(server, user=config.AD_USER, password=config.AD_PASSWORD, auto_bind=True)
|
entries = search_by_user_id(search_id, attributes=['extensionAttribute2'])
|
||||||
safe_user_id = escape_filter_chars(user_id)
|
|
||||||
short_username = user_id.split('@')[0]
|
|
||||||
search_filter = f"(&(objectClass=user)(|(mail={safe_user_id})(userPrincipalName={safe_user_id})(userPrincipalName={escape_filter_chars(f'{short_username}@sibcem.ru')})(sAMAccountName={escape_filter_chars(short_username)})))"
|
|
||||||
|
|
||||||
conn.search(config.AD_BASE, search_filter, attributes=['extensionAttribute2'])
|
if entries and 'extensionAttribute2' in entries[0] and entries[0].extensionAttribute2.value:
|
||||||
|
return str(entries[0].extensionAttribute2.value).strip()
|
||||||
|
|
||||||
if conn.entries and 'extensionAttribute2' in conn.entries[0] and conn.entries[0].extensionAttribute2.value:
|
|
||||||
return str(conn.entries[0].extensionAttribute2.value).strip()
|
|
||||||
raise Exception("Поле extensionAttribute2 не заполнено в AD.")
|
raise Exception("Поле extensionAttribute2 не заполнено в AD.")
|
||||||
except Exception as e: raise Exception(f"AD Card Query Error: {e}")
|
except Exception as e:
|
||||||
|
raise Exception(f"AD Card Query Error: {e}")
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# МОДНОЕ УНИВЕРСАЛЬНОЕ ЯДРО ПАГИНАЦИИ СПИСКОВ ДЛЯ ЧАТА
|
# МОДНОЕ УНИВЕРСАЛЬНОЕ ЯДРО ПАГИНАЦИИ СПИСКОВ ДЛЯ ЧАТА
|
||||||
@@ -200,18 +198,64 @@ async def send_dynamic_lk_main_menu(msg: Message, user_id: str):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Не удалось собрать динамический заголовок ЛК: {e}")
|
logger.error(f"Не удалось собрать динамический заголовок ЛК: {e}")
|
||||||
|
await handle_lk_error(msg, user_id, "Сборка заголовка ЛК", str(e))
|
||||||
header = "👤 <b>Личный кабинет</b>\n\n"
|
header = "👤 <b>Личный кабинет</b>\n\n"
|
||||||
|
|
||||||
menu_text = lk_main_menu_text(header, EMOJI_DIGITS)
|
menu_text = lk_main_menu_text(header, EMOJI_DIGITS)
|
||||||
await msg.answer(menu_text, parse_mode="html")
|
await msg.answer(menu_text, parse_mode="html")
|
||||||
|
|
||||||
async def send_tabel_months_menu(msg: Message, user_id: str):
|
async def send_tabel_months_menu(msg: Message, user_id: str):
|
||||||
now = datetime(2026, 6, 3)
|
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||||
prev_1 = now.replace(day=1) - timedelta(days=1)
|
months_ru = {
|
||||||
prev_2 = prev_1.replace(day=1) - timedelta(days=1)
|
1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь",
|
||||||
months_ru = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь", 7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"}
|
7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"
|
||||||
|
}
|
||||||
|
|
||||||
menu_text = lk_tabel_months_menu_text(prev_1, prev_2, months_ru, EMOJI_DIGITS)
|
now = datetime.now()
|
||||||
|
# Генерируем список из 4 месяцев: текущий, прошлый, позапрошлый, за два месяца
|
||||||
|
candidates = []
|
||||||
|
m = now
|
||||||
|
for _ in range(4):
|
||||||
|
candidates.append((m.year, m.month))
|
||||||
|
m = m.replace(day=1) - timedelta(days=1)
|
||||||
|
|
||||||
|
# Проверяем какие месяцы имеют данные в БД
|
||||||
|
import lk.info_service as info_service
|
||||||
|
conn = info_service.get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
available = []
|
||||||
|
for year, month in candidates:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT TOP 1 1 FROM UOV_SELFSERVICE_TB_TABEL WHERE EMP_ID IN (SELECT ID FROM UOV_SELFSERVICE_PR_EMP WHERE CARD_ID=%s) AND YEAR(D)=%s AND MONTH(D)=%s",
|
||||||
|
(card_id, year, month)
|
||||||
|
)
|
||||||
|
if cursor.fetchone():
|
||||||
|
available.append((year, month))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not available:
|
||||||
|
await msg.answer("⚠️ <b>Данные табеля отсутствуют.</b>\n\nЗа указанный период данные в системе не найдены.", parse_mode="html")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Строим динамическое меню
|
||||||
|
lines = ["⏱ <b>Выберите месяц для просмотра табеля:</b>\n\n"]
|
||||||
|
for idx, (year, month) in enumerate(available, 1):
|
||||||
|
digit = EMOJI_DIGITS.get(str(idx), str(idx))
|
||||||
|
lines.append(f"{digit} — {months_ru[month]} {year} г.")
|
||||||
|
|
||||||
|
lines.append(f"\n<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>")
|
||||||
|
menu_text = "\n".join(lines)
|
||||||
|
|
||||||
|
# Сохраняем доступные месяцы в сессию для обработчика
|
||||||
|
user_page_sessions[user_id] = {
|
||||||
|
"tabel_months": available,
|
||||||
|
"current_page": 0,
|
||||||
|
"per_page": 5,
|
||||||
|
"static_header": "",
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
set_state(user_id, "LK_TABEL_MONTHS")
|
set_state(user_id, "LK_TABEL_MONTHS")
|
||||||
await msg.answer(menu_text, parse_mode="html")
|
await msg.answer(menu_text, parse_mode="html")
|
||||||
|
|
||||||
@@ -267,13 +311,22 @@ async def build_confirmation_text(user_id: str, doc: dict) -> str:
|
|||||||
async def lk_router_handler(msg: Message):
|
async def lk_router_handler(msg: Message):
|
||||||
user_id = msg.from_user.id
|
user_id = msg.from_user.id
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
if current_time - user_last_request.get(user_id, 0) < USER_COOLDOWN_SEC: return
|
current_state = get_state(user_id)
|
||||||
|
|
||||||
|
# Если пользователь находится внутри меню ЛК, помечаем сообщение обработанным,
|
||||||
|
# чтобы оно не улетало в ServiceDesk / Instruct роутеры
|
||||||
|
if current_state and current_state.startswith("LK_"):
|
||||||
|
msg.handled = True
|
||||||
|
|
||||||
|
# Проверка на слишком частые клики (кулдаун)
|
||||||
|
if current_time - user_last_request.get(user_id, 0) < USER_COOLDOWN_SEC:
|
||||||
|
return
|
||||||
user_last_request[user_id] = current_time
|
user_last_request[user_id] = current_time
|
||||||
|
|
||||||
current_state = get_state(user_id)
|
|
||||||
nav_text = lk_nav_text(EMOJI_DIGITS)
|
nav_text = lk_nav_text(EMOJI_DIGITS)
|
||||||
|
|
||||||
if not msg.text: return
|
if not msg.text:
|
||||||
|
return
|
||||||
cmd = msg.text.strip().lower()
|
cmd = msg.text.strip().lower()
|
||||||
|
|
||||||
for raw_num, emoji_num in EMOJI_DIGITS.items():
|
for raw_num, emoji_num in EMOJI_DIGITS.items():
|
||||||
@@ -372,7 +425,6 @@ async def lk_router_handler(msg: Message):
|
|||||||
set_state(user_id, "LK_DOCS_MENU")
|
set_state(user_id, "LK_DOCS_MENU")
|
||||||
await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html")
|
await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html")
|
||||||
else:
|
else:
|
||||||
# 🎯 ИСПРАВЛЕНО: Вместо спама копии меню ЛК выводим строгое предупреждение
|
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
elif current_state == "LK_TABEL_MONTHS":
|
elif current_state == "LK_TABEL_MONTHS":
|
||||||
@@ -384,17 +436,31 @@ async def lk_router_handler(msg: Message):
|
|||||||
elif cmd == "9":
|
elif cmd == "9":
|
||||||
set_state(user_id, "LK_MODE")
|
set_state(user_id, "LK_MODE")
|
||||||
await send_dynamic_lk_main_menu(msg, user_id)
|
await send_dynamic_lk_main_menu(msg, user_id)
|
||||||
elif cmd in ["1", "2"]:
|
elif cmd.isdigit():
|
||||||
log_menu_stats(user_id, "Личный кабинет", "Просмотр табеля за месяц")
|
session = user_page_sessions.get(user_id)
|
||||||
|
if not session or not session.get("tabel_months"):
|
||||||
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
return
|
||||||
|
idx = int(cmd) - 1
|
||||||
|
available = session["tabel_months"]
|
||||||
|
if idx < 0 or idx >= len(available):
|
||||||
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
return
|
||||||
|
target_year, target_month = available[idx]
|
||||||
|
log_menu_stats(user_id, "Личный кабинет", f"Просмотр табеля ({target_month}.{target_year})")
|
||||||
await msg.answer(lk_tabel_loading(EMOJI_DIGITS), parse_mode="html")
|
await msg.answer(lk_tabel_loading(EMOJI_DIGITS), parse_mode="html")
|
||||||
try:
|
try:
|
||||||
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||||
now = datetime(2026, 6, 3)
|
tabel_report = await asyncio.to_thread(
|
||||||
target_date = now.replace(day=1) - timedelta(days=1) if cmd == "1" else (now.replace(day=1) - timedelta(days=1)).replace(day=1) - timedelta(days=1)
|
get_tabel_report_text,
|
||||||
tabel_report = await asyncio.to_thread(get_tabel_report_text, card_id, target_date.year, target_date.month)
|
card_id,
|
||||||
|
target_year,
|
||||||
|
target_month
|
||||||
|
)
|
||||||
set_state(user_id, "LK_TABEL_VIEW")
|
set_state(user_id, "LK_TABEL_VIEW")
|
||||||
await msg.answer(tabel_report + nav_text, parse_mode="html")
|
await msg.answer(tabel_report + nav_text, parse_mode="html")
|
||||||
except Exception as e: await handle_lk_error(msg, user_id, "Формирование табеля", str(e))
|
except Exception as e:
|
||||||
|
await handle_lk_error(msg, user_id, "Формирование табеля", str(e))
|
||||||
else:
|
else:
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
@@ -440,7 +506,8 @@ async def lk_router_handler(msg: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not session:
|
if not session:
|
||||||
await msg.answer("⚠️ <i>Сессия авторизации не найдена. Начните сначала.</i>", parse_mode="html")
|
logger.warning(f"Сессия авторизации не найдена для пользователя {user_id}")
|
||||||
|
await handle_lk_error(msg, user_id, "Сессия авторизации не найдена", f"user_id={user_id}")
|
||||||
set_state(user_id, "LK_MODE")
|
set_state(user_id, "LK_MODE")
|
||||||
await send_dynamic_lk_main_menu(msg, user_id)
|
await send_dynamic_lk_main_menu(msg, user_id)
|
||||||
return
|
return
|
||||||
@@ -554,7 +621,8 @@ async def lk_router_handler(msg: Message):
|
|||||||
else:
|
else:
|
||||||
raise Exception(f"Внутренний каскадный шлюз недоступен: {channel_info}")
|
raise Exception(f"Внутренний каскадный шлюз недоступен: {channel_info}")
|
||||||
else:
|
else:
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
logger.warning(f"Неверный индекс месяца для пользователя {user_id}: cmd={cmd}")
|
||||||
|
await handle_lk_error(msg, user_id, "Неверный индекс месяца", f"user_id={user_id}, cmd={cmd}")
|
||||||
except Exception as e: await handle_lk_error(msg, user_id, "Инициализация 2FA расчетного листа", str(e))
|
except Exception as e: await handle_lk_error(msg, user_id, "Инициализация 2FA расчетного листа", str(e))
|
||||||
else:
|
else:
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
@@ -578,7 +646,6 @@ async def lk_router_handler(msg: Message):
|
|||||||
set_state(user_id, "LK_DOCS_QUANTITY")
|
set_state(user_id, "LK_DOCS_QUANTITY")
|
||||||
await msg.answer(lk_docs_quantity_prompt(EMOJI_DIGITS), parse_mode="html")
|
await msg.answer(lk_docs_quantity_prompt(EMOJI_DIGITS), parse_mode="html")
|
||||||
else:
|
else:
|
||||||
# 🎯 ИСПРАВЛЕНО: Заменен спам списка документов на чистую ошибку ввода
|
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
elif current_state == "LK_DOCS_QUANTITY":
|
elif current_state == "LK_DOCS_QUANTITY":
|
||||||
@@ -606,7 +673,6 @@ async def lk_router_handler(msg: Message):
|
|||||||
confirm_txt = await build_confirmation_text(user_id, user_docs_session[user_id])
|
confirm_txt = await build_confirmation_text(user_id, user_docs_session[user_id])
|
||||||
await msg.answer(confirm_txt, parse_mode="html")
|
await msg.answer(confirm_txt, parse_mode="html")
|
||||||
else:
|
else:
|
||||||
# 🎯 ИСПРАВЛЕНО: Вместо кастомной подсказки выводим единый стандарт
|
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
elif current_state == "LK_DOCS_PERIOD":
|
elif current_state == "LK_DOCS_PERIOD":
|
||||||
@@ -619,14 +685,12 @@ async def lk_router_handler(msg: Message):
|
|||||||
if cmd in ["1", "2", "3", "4", "5", "6", "7", "8"]:
|
if cmd in ["1", "2", "3", "4", "5", "6", "7", "8"]:
|
||||||
user_docs_session[user_id]['period'] = f"за {datetime.now().year - (int(cmd) - 1)} год"
|
user_docs_session[user_id]['period'] = f"за {datetime.now().year - (int(cmd) - 1)} год"
|
||||||
else:
|
else:
|
||||||
# 🎯 ИСПРАВЛЕНО: Убран спам меню годов при абракадабре
|
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
return
|
return
|
||||||
elif doc_id == "5":
|
elif doc_id == "5":
|
||||||
if cmd == "1": user_docs_session[user_id]['period'] = "до 1.5 лет"
|
if cmd == "1": user_docs_session[user_id]['period'] = "до 1.5 лет"
|
||||||
elif cmd == "2": user_docs_session[user_id]['period'] = "до 3 лет"
|
elif cmd == "2": user_docs_session[user_id]['period'] = "до 3 лет"
|
||||||
else:
|
else:
|
||||||
# 🎯 ИСПРАВЛЕНО: Убран спам меню возраста при некорректном вводе
|
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
return
|
return
|
||||||
elif doc_id == "3":
|
elif doc_id == "3":
|
||||||
@@ -670,7 +734,6 @@ async def lk_router_handler(msg: Message):
|
|||||||
except Exception as e: await handle_lk_error(msg, user_id, f"Заказ справки {user_docs_session.get(user_id, {}).get('doc_id')}", str(e)); set_state(user_id, "LK_MODE")
|
except Exception as e: await handle_lk_error(msg, user_id, f"Заказ справки {user_docs_session.get(user_id, {}).get('doc_id')}", str(e)); set_state(user_id, "LK_MODE")
|
||||||
finally: user_docs_session.pop(user_id, None)
|
finally: user_docs_session.pop(user_id, None)
|
||||||
else:
|
else:
|
||||||
# 🎯 ИСПРАВЛЕНО: Удален спам-текст подтверждения
|
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
elif current_state == "LK_DOCS_SUCCESS":
|
elif current_state == "LK_DOCS_SUCCESS":
|
||||||
@@ -678,5 +741,5 @@ async def lk_router_handler(msg: Message):
|
|||||||
if cmd == "0": clear_state(user_id); from utils.menu import MENU_TEXT; await msg.answer(MENU_TEXT, parse_mode="html")
|
if cmd == "0": clear_state(user_id); from utils.menu import MENU_TEXT; await msg.answer(MENU_TEXT, parse_mode="html")
|
||||||
elif cmd == "9": set_state(user_id, "LK_DOCS_MENU"); await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html")
|
elif cmd == "9": set_state(user_id, "LK_DOCS_MENU"); await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html")
|
||||||
else:
|
else:
|
||||||
# 🎯 ИСПРАВЛЕНО: Унифицировано под общий шаблон ошибки
|
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Диагностика данных сотрудника для модуля ЛК (расчетные листки)."""
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 1. Загрузка .env и путей
|
||||||
|
# ============================================================
|
||||||
|
BASE_DIR = "/opt/trueconf_bot"
|
||||||
|
if os.path.exists(BASE_DIR):
|
||||||
|
os.chdir(BASE_DIR)
|
||||||
|
else:
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
def load_env(filepath):
|
||||||
|
try:
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and "=" in line:
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
os.environ[key.strip()] = value.strip(' "\'\r\n')
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"⚠️ Файл {filepath} не найден.")
|
||||||
|
|
||||||
|
load_env(os.path.join(BASE_DIR, "config", ".env"))
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 2. Загрузка паролей из Passwork (AD и SQL)
|
||||||
|
# ============================================================
|
||||||
|
CREDENTIALS_MAP = {
|
||||||
|
"AD_PASSWORD": os.getenv("PW_ID_AD", "").strip(' "\'\r\n'),
|
||||||
|
"DB_PASSWORD": os.getenv("PW_ID_SQL", "").strip(' "\'\r\n'),
|
||||||
|
}
|
||||||
|
|
||||||
|
passwork_paths = [
|
||||||
|
"/opt/passwork",
|
||||||
|
os.path.join(BASE_DIR, "passwork"),
|
||||||
|
os.path.join(BASE_DIR, "config", "passwork"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for pw_path in passwork_paths:
|
||||||
|
if os.path.isdir(pw_path) and pw_path not in sys.path:
|
||||||
|
sys.path.insert(0, pw_path)
|
||||||
|
|
||||||
|
if BASE_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, BASE_DIR)
|
||||||
|
|
||||||
|
AD_USER, AD_PASSWORD, DB_USER, DB_PASSWORD = "", "", "", ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from passwork import get_passwork_secrets
|
||||||
|
required_cards = [name for name in CREDENTIALS_MAP.values() if name]
|
||||||
|
passwork_pool = get_passwork_secrets(required_cards=required_cards)
|
||||||
|
|
||||||
|
ad_card_name = CREDENTIALS_MAP.get("AD_PASSWORD")
|
||||||
|
if ad_card_name and ad_card_name in passwork_pool:
|
||||||
|
card_ad = passwork_pool[ad_card_name]
|
||||||
|
AD_USER = card_ad.get("login", "").strip(' "\'\r\n')
|
||||||
|
AD_PASSWORD = card_ad.get("password", "").strip(' "\'\r\n')
|
||||||
|
|
||||||
|
sql_card_name = CREDENTIALS_MAP.get("DB_PASSWORD")
|
||||||
|
if sql_card_name and sql_card_name in passwork_pool:
|
||||||
|
card_sql = passwork_pool[sql_card_name]
|
||||||
|
DB_USER = card_sql.get("login", "").strip(' "\'\r\n')
|
||||||
|
DB_PASSWORD = card_sql.get("password", "").strip(' "\'\r\n')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Ошибка загрузки из Passwork: {e}")
|
||||||
|
|
||||||
|
if not AD_USER: AD_USER = os.getenv("AD_USER", "")
|
||||||
|
if not AD_PASSWORD: AD_PASSWORD = os.getenv("AD_PASSWORD", "")
|
||||||
|
if not DB_USER: DB_USER = os.getenv("DB_USER", "")
|
||||||
|
if not DB_PASSWORD: DB_PASSWORD = os.getenv("DB_PASSWORD", "")
|
||||||
|
|
||||||
|
SQL_SERVER = os.getenv("SQL_SERVER", "SRVKEM-MOBILEIN.sibcem.ru")
|
||||||
|
SQL_DB_NAME = os.getenv("SQL_DB_NAME", "BossCopy")
|
||||||
|
AD_SERVER = os.getenv("AD_SERVER", "172.16.20.20")
|
||||||
|
AD_BASE = os.getenv("AD_BASE", "DC=sibcem,DC=ru")
|
||||||
|
|
||||||
|
import ldap3
|
||||||
|
import pymssql
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 3. Поиск сотрудника в Active Directory
|
||||||
|
# ============================================================
|
||||||
|
email = "la.guseynova@sibcem.ru"
|
||||||
|
search_id = email.replace("@tcs.sibcem.ru", "@sibcem.ru")
|
||||||
|
short_name = search_id.split("@")[0]
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Диагностика сотрудника: {email}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
print("\n--- 0. Поиск в AD ---")
|
||||||
|
card_id_ad = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
server = ldap3.Server(AD_SERVER, connect_timeout=5)
|
||||||
|
bind_user = AD_USER
|
||||||
|
if bind_user and "@" not in bind_user and "\\" not in bind_user:
|
||||||
|
bind_user = f"{bind_user}@sibcem.ru"
|
||||||
|
|
||||||
|
conn = ldap3.Connection(
|
||||||
|
server,
|
||||||
|
user=bind_user,
|
||||||
|
password=AD_PASSWORD,
|
||||||
|
auto_bind=True,
|
||||||
|
auto_referrals=False
|
||||||
|
)
|
||||||
|
|
||||||
|
attributes_list = ["cn", "sAMAccountName", "mail", "userPrincipalName", "proxyAddresses", "extensionAttribute2"]
|
||||||
|
search_filter = f"(|(sAMAccountName={short_name})(mail={search_id})(userPrincipalName={search_id})(proxyAddresses=*:{search_id}*)(proxyAddresses=*{short_name}*)(anr={short_name}))"
|
||||||
|
|
||||||
|
conn.search(
|
||||||
|
search_base=AD_BASE,
|
||||||
|
search_filter=search_filter,
|
||||||
|
attributes=attributes_list
|
||||||
|
)
|
||||||
|
|
||||||
|
if conn.entries:
|
||||||
|
entry = conn.entries[0]
|
||||||
|
cn = str(entry.cn)
|
||||||
|
sam = str(entry.sAMAccountName) if 'sAMAccountName' in entry else ""
|
||||||
|
mail_val = str(entry.mail) if 'mail' in entry else ""
|
||||||
|
upn = str(entry.userPrincipalName) if 'userPrincipalName' in entry else ""
|
||||||
|
card_id_ad = str(entry.extensionAttribute2) if 'extensionAttribute2' in entry else None
|
||||||
|
|
||||||
|
print(" ✅ НАЙДЕН В AD!")
|
||||||
|
print(f" CN: {cn}")
|
||||||
|
print(f" sAMAccountName: {sam}")
|
||||||
|
print(f" mail: {mail_val}")
|
||||||
|
print(f" UPN: {upn}")
|
||||||
|
print(f" extensionAttribute2 (CARD_ID из AD): {card_id_ad}")
|
||||||
|
else:
|
||||||
|
print(" ❌ НЕ НАЙДЕН в AD!")
|
||||||
|
|
||||||
|
conn.unbind()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Ошибка обращения к AD: {e}")
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 4. Подключение к SQL Server
|
||||||
|
# ============================================================
|
||||||
|
print("\n--- 1. Подключение к SQL Server ---")
|
||||||
|
try:
|
||||||
|
conn_sql = pymssql.connect(
|
||||||
|
server=SQL_SERVER,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASSWORD,
|
||||||
|
database=SQL_DB_NAME,
|
||||||
|
charset='cp1251',
|
||||||
|
login_timeout=10
|
||||||
|
)
|
||||||
|
cursor = conn_sql.cursor()
|
||||||
|
print(" ✅ Подключено к SQL Server!")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Ошибка подключения к SQL: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 5. Определение CARD_ID
|
||||||
|
# ============================================================
|
||||||
|
card_id = card_id_ad
|
||||||
|
|
||||||
|
if not card_id:
|
||||||
|
print("\n--- 2. Поиск CARD_ID по ФИО в PR_CARD ---")
|
||||||
|
cursor.execute("SELECT c.ID, c.NAME FROM UOV_SELFSERVICE_PR_CARD c WHERE c.NAME LIKE %s", ("%Гусейнова%",))
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
if rows:
|
||||||
|
card_id = str(rows[0][0])
|
||||||
|
print(f" ✅ НАЙДЕН по ФИО: CARD_ID={card_id} ({rows[0][1]})")
|
||||||
|
|
||||||
|
if not card_id:
|
||||||
|
print(" ❌ Не удалось определить CARD_ID!")
|
||||||
|
conn_sql.close()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\n Используем CARD_ID: {card_id}")
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 6. Проверка доступных периодов (get_latest_payslip_months)
|
||||||
|
# ============================================================
|
||||||
|
print("\n--- 3. Расчетные периоды (SL_YTAX) ---")
|
||||||
|
q_months = """
|
||||||
|
SELECT DISTINCT TOP 6 (t.TYEAR * 12 + t.TMONTH) as CMONTH, t.TYEAR, t.TMONTH
|
||||||
|
FROM UOV_SELFSERVICE_PR_EMP e
|
||||||
|
JOIN UOV_SELFSERVICE_PR_CARD c on c.ID = e.CARD_ID
|
||||||
|
JOIN UOV_SELFSERVICE_SL_YTAX t on t.CARD_ID = e.CARD_ID
|
||||||
|
JOIN UOV_SELFSERVICE_PR_TRANS tr on tr.EMP_ID = e.ID
|
||||||
|
JOIN UOV_SELFSERVICE_ST_APPOINT a on a.ID = tr.APPOINT_ID
|
||||||
|
JOIN UOV_SELFSERVICE_HR_FIRM f on f.ID = e.FIRM_ID
|
||||||
|
WHERE c.ID = %s
|
||||||
|
ORDER BY CMONTH DESC
|
||||||
|
"""
|
||||||
|
cursor.execute(q_months, (card_id,))
|
||||||
|
month_rows = cursor.fetchall()
|
||||||
|
|
||||||
|
if not month_rows:
|
||||||
|
print(" ❌ Отсутствуют расчетные периоды для данного CARD_ID!")
|
||||||
|
conn_sql.close()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f" Найдено периодов: {len(month_rows)}")
|
||||||
|
for r in month_rows:
|
||||||
|
print(f" • CMONTH={r[0]} ({r[1]}-{r[2]:02d})")
|
||||||
|
|
||||||
|
target_cmonth = month_rows[0][0]
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 7. Тест определения организации (q_firm) для периода
|
||||||
|
# ============================================================
|
||||||
|
print(f"\n--- 4. Определение FIRM_ID для CMONTH={target_cmonth} ---")
|
||||||
|
tyear = (target_cmonth - 1) // 12
|
||||||
|
tmonth = (target_cmonth - 1) % 12 + 1
|
||||||
|
_, last_day = calendar.monthrange(tyear, tmonth)
|
||||||
|
|
||||||
|
date_start = f"{tyear}-{tmonth:02d}-01"
|
||||||
|
date_end = f"{tyear}-{tmonth:02d}-{last_day:02d}"
|
||||||
|
date_params = (date_start, date_start, date_end, date_end, date_end)
|
||||||
|
|
||||||
|
q_firm = """
|
||||||
|
select e.FIRM_ID, f.SNAME from (select e.id, e.FIRM_ID from UOV_SELFSERVICE_PR_EMP e
|
||||||
|
join UOV_SELFSERVICE_PR_CARD c on c.ID=e.CARD_ID where c.ID = %s
|
||||||
|
group by e.ID, e.FIRM_ID ) as e
|
||||||
|
join UOV_SELFSERVICE_PR_TRANS t on t.EMP_ID = e.ID
|
||||||
|
join UOV_SELFSERVICE_HR_FIRM f on f.ID = e.FIRM_ID
|
||||||
|
where ((t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO = '1999-12-31'))
|
||||||
|
group by e.FIRM_ID, f.SNAME
|
||||||
|
"""
|
||||||
|
cursor.execute(q_firm, (card_id, *date_params))
|
||||||
|
firm_row = cursor.fetchone()
|
||||||
|
|
||||||
|
if firm_row:
|
||||||
|
print(f" ✅ q_firm УСПЕШНО определил FIRM_ID={firm_row[0]} ({firm_row[1]}) за {tyear}-{tmonth:02d}")
|
||||||
|
else:
|
||||||
|
print(f" ❌ q_firm НЕ СМОГ определить FIRM_ID за {tyear}-{tmonth:02d}!")
|
||||||
|
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("Диагностика завершена")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
conn_sql.close()
|
||||||
+22
-13
@@ -13,7 +13,10 @@ from ldap3.utils.conv import escape_filter_chars
|
|||||||
TEST_USER = "man.bogov@tcs.sibcem.ru"
|
TEST_USER = "man.bogov@tcs.sibcem.ru"
|
||||||
|
|
||||||
AD_SERVER = "ldap://172.16.20.20"
|
AD_SERVER = "ldap://172.16.20.20"
|
||||||
AD_BASE = "OU=-Пользователи,DC=sibcem,DC=ru"
|
AD_BASES = (
|
||||||
|
"OU=-Пользователи,DC=sibcem,DC=ru",
|
||||||
|
"OU=Планшеты,OU=enabled,OU=БезКомпьютеров,DC=sibcem,DC=ru",
|
||||||
|
)
|
||||||
|
|
||||||
AD_USER = "sdesk-mail"
|
AD_USER = "sdesk-mail"
|
||||||
AD_PASSWORD = "fne?e!q.m8phcrGVAcqr"
|
AD_PASSWORD = "fne?e!q.m8phcrGVAcqr"
|
||||||
@@ -32,7 +35,6 @@ DB_PASSWORD = "Bav:fX#UwH8%atv4"
|
|||||||
def get_phone_from_ad(user_id: str) -> str:
|
def get_phone_from_ad(user_id: str) -> str:
|
||||||
|
|
||||||
server = Server(AD_SERVER, get_info=ALL)
|
server = Server(AD_SERVER, get_info=ALL)
|
||||||
|
|
||||||
conn = Connection(
|
conn = Connection(
|
||||||
server,
|
server,
|
||||||
user=AD_USER,
|
user=AD_USER,
|
||||||
@@ -41,22 +43,29 @@ def get_phone_from_ad(user_id: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
safe_user_id = escape_filter_chars(user_id)
|
safe_user_id = escape_filter_chars(user_id)
|
||||||
|
|
||||||
short_username = user_id.split("@")[0]
|
short_username = user_id.split("@")[0]
|
||||||
|
|
||||||
search_filter = (
|
search_filter = (
|
||||||
f"(&(objectClass=user)"
|
"(&(objectClass=user)"
|
||||||
f"(|(mail={safe_user_id})"
|
"(|(mail={})"
|
||||||
f"(userPrincipalName={safe_user_id})"
|
"(userPrincipalName={})"
|
||||||
f"(userPrincipalName={escape_filter_chars(f'{short_username}@sibcem.ru')})"
|
"(userPrincipalName={}))"
|
||||||
f"(sAMAccountName={escape_filter_chars(short_username)})))"
|
"(sAMAccountName={}))".format(
|
||||||
|
safe_user_id,
|
||||||
|
safe_user_id,
|
||||||
|
escape_filter_chars(short_username) + "@sibcem.ru",
|
||||||
|
escape_filter_chars(short_username),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.search(
|
for base in AD_BASES:
|
||||||
AD_BASE,
|
conn.search(
|
||||||
search_filter,
|
base,
|
||||||
attributes=['mobile', 'telephoneNumber']
|
search_filter,
|
||||||
)
|
attributes=['mobile', 'telephoneNumber']
|
||||||
|
)
|
||||||
|
if conn.entries:
|
||||||
|
break
|
||||||
|
|
||||||
if not conn.entries:
|
if not conn.entries:
|
||||||
raise Exception("Пользователь не найден в AD")
|
raise Exception("Пользователь не найден в AD")
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import sys
|
import sys
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
import os
|
import os
|
||||||
import psutil
|
import psutil
|
||||||
import time
|
import time
|
||||||
@@ -18,13 +17,6 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logging.getLogger('trueconf.client.chatbot').propagate = False
|
logging.getLogger('trueconf.client.chatbot').propagate = False
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
sd_workflow_logger = logging.getLogger('sd_workflow')
|
|
||||||
sd_workflow_logger.setLevel(logging.INFO)
|
|
||||||
if not sd_workflow_logger.handlers:
|
|
||||||
handler = RotatingFileHandler(os.path.join(os.path.dirname(__file__), 'logs', 'sd_workflow.log'), maxBytes=10*1024*1024, backupCount=5)
|
|
||||||
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
|
||||||
handler.setFormatter(formatter)
|
|
||||||
sd_workflow_logger.addHandler(handler)
|
|
||||||
|
|
||||||
# Добавляем путь к проекту в sys.path
|
# Добавляем путь к проекту в sys.path
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
@@ -79,7 +71,7 @@ log_ram_usage("Старт скрипта (базовый вес)")
|
|||||||
from photo_bot.handlers import router as photo_router, PHOTO_MAIN_MENU_TEXT
|
from photo_bot.handlers import router as photo_router, PHOTO_MAIN_MENU_TEXT
|
||||||
log_ram_usage("После импорта photo_bot")
|
log_ram_usage("После импорта photo_bot")
|
||||||
|
|
||||||
from service_desk.handlers import router as sd_router, SD_MAIN_MENU_TEXT, sd_sessions
|
from service_desk.handlers import router as sd_router, SD_MAIN_MENU_TEXT
|
||||||
log_ram_usage("После импорта service_desk")
|
log_ram_usage("После импорта service_desk")
|
||||||
|
|
||||||
from transcription_bot.handlers import router as transcription_router, TRANSCRIPTION_MAIN_MENU_TEXT
|
from transcription_bot.handlers import router as transcription_router, TRANSCRIPTION_MAIN_MENU_TEXT
|
||||||
@@ -88,6 +80,9 @@ log_ram_usage("После импорта transcription_router (Виспер)")
|
|||||||
from search_bot.handlers import router as search_router, SEARCH_MAIN_MENU_TEXT
|
from search_bot.handlers import router as search_router, SEARCH_MAIN_MENU_TEXT
|
||||||
log_ram_usage("После импорта search_router")
|
log_ram_usage("После импорта search_router")
|
||||||
|
|
||||||
|
from instruct.handlers import router as instruct_router
|
||||||
|
log_ram_usage("После импорта instruct_router")
|
||||||
|
|
||||||
from lk.handlers import router as lk_router
|
from lk.handlers import router as lk_router
|
||||||
log_ram_usage("После импорта lk_router (Личный кабинет)")
|
log_ram_usage("После импорта lk_router (Личный кабинет)")
|
||||||
|
|
||||||
@@ -115,79 +110,21 @@ async def main_menu(msg: Message):
|
|||||||
menu_shown = USER_MENU_SHOWN.get(user_id, False)
|
menu_shown = USER_MENU_SHOWN.get(user_id, False)
|
||||||
if msg.text:
|
if msg.text:
|
||||||
cmd = msg.text.strip().lower()
|
cmd = msg.text.strip().lower()
|
||||||
|
# Обработка "0" — возврат в главное меню
|
||||||
|
if cmd in ("0", "меню"):
|
||||||
|
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||||
|
return
|
||||||
action = MENU_MAP.get(cmd)
|
action = MENU_MAP.get(cmd)
|
||||||
if action == "SD":
|
if action == "SD":
|
||||||
USER_MENU_SHOWN[user_id] = True
|
USER_MENU_SHOWN[user_id] = True
|
||||||
set_state(user_id, "SD_MODE")
|
set_state(user_id, "SD_MODE")
|
||||||
log_menu_stats(user_id, "Service Desk", "Вход")
|
log_menu_stats(user_id, "Service Desk", "Вход")
|
||||||
# Помечаем сообщение как обработанное, чтобы sd_module_handler не перезаписал
|
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
|
||||||
msg.handled = True
|
|
||||||
# Инициализируем сессию для service_desk
|
|
||||||
if user_id not in sd_sessions:
|
|
||||||
sd_sessions[user_id] = {"step": "choose_template", "files_queue": [], "post_create_queue": [], "templates": [], "template_names": {}, "template_menu_text": ""}
|
|
||||||
# Загружаем шаблоны и встраиваем их в текст
|
|
||||||
try:
|
|
||||||
import json, asyncio, httpx, urllib3
|
|
||||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
||||||
# Импортируем SD_URL и SD_TOKEN из config
|
|
||||||
import importlib.util
|
|
||||||
spec = importlib.util.spec_from_file_location("custom_config", os.path.join(os.path.dirname(os.path.abspath(__file__)), "config", "config.py"))
|
|
||||||
custom_config = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(custom_config)
|
|
||||||
SD_URL = custom_config.SD_URL
|
|
||||||
SD_TOKEN = custom_config.SD_TOKEN
|
|
||||||
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"}
|
|
||||||
input_data = {"list_info": {"row_count": 100, "start_index": 1}}
|
|
||||||
params = {"input_data": json.dumps(input_data)}
|
|
||||||
url = f"{SD_URL}/api/v3/request_templates"
|
|
||||||
async with httpx.AsyncClient(verify=False) as client:
|
|
||||||
resp = await client.get(url, headers=headers, params=params, timeout=30.0)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
data = resp.json()
|
|
||||||
all_templates = data.get("request_templates", [])
|
|
||||||
pre_filtered = [
|
|
||||||
t for t in all_templates
|
|
||||||
if t.get("status") == "ACTIVE"
|
|
||||||
and not t.get("inactive", False)
|
|
||||||
and str(t.get("id")) != "2"
|
|
||||||
]
|
|
||||||
async def check_template(t, cl):
|
|
||||||
try:
|
|
||||||
t_resp = await client.get(url + "/" + str(t["id"]), headers=headers, timeout=15.0)
|
|
||||||
if t_resp.status_code == 200:
|
|
||||||
detailed = t_resp.json().get("request_template", {})
|
|
||||||
return t if detailed.get("show_to_requester") is True else None
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
tasks = [check_template(t, client) for t in pre_filtered]
|
|
||||||
results = await asyncio.gather(*tasks)
|
|
||||||
templates = [t for t in results if t is not None]
|
|
||||||
template_lines = ""
|
|
||||||
for idx, t in enumerate(templates):
|
|
||||||
num = idx + 1
|
|
||||||
digit = f"{num}⃣" if num <= 9 else "0⃣" if num == 10 else str(num)
|
|
||||||
template_lines += f"{digit} - {t['name']}\n"
|
|
||||||
# Сохраняем шаблоны в sd_sessions для sd_module_handler
|
|
||||||
sd_sessions[user_id]["templates"] = templates
|
|
||||||
sd_sessions[user_id]["template_names"] = {str(i+1): str(t["id"]) for i, t in enumerate(templates)}
|
|
||||||
sd_sessions[user_id]["template_menu_text"] = template_lines.strip()
|
|
||||||
template_text = f"🎫 Режим создания заявки в Service Desk\n\nВыберите, пожалуйста, шаблон подходящий под вашу проблему\n\n{template_lines}"
|
|
||||||
await msg.answer(template_text, parse_mode="html")
|
|
||||||
msg.handled = True
|
|
||||||
else:
|
|
||||||
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
|
|
||||||
msg.handled = True
|
|
||||||
except Exception as e:
|
|
||||||
sd_workflow_logger.error(f"Ошибка шаблонов: {e}")
|
|
||||||
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
|
|
||||||
msg.handled = True
|
|
||||||
return
|
return
|
||||||
elif action == "PHOTO":
|
elif action == "PHOTO":
|
||||||
USER_MENU_SHOWN[user_id] = True
|
USER_MENU_SHOWN[user_id] = True
|
||||||
set_state(user_id, "PHOTO_MODE")
|
from photo_bot.handlers import send_dynamic_photo_main_menu
|
||||||
log_menu_stats(user_id, "Photo Bot", "Вход")
|
await send_dynamic_photo_main_menu(msg, user_id)
|
||||||
await msg.answer(PHOTO_MAIN_MENU_TEXT, parse_mode="html")
|
|
||||||
return
|
return
|
||||||
elif action == "TRANSCRIPTION":
|
elif action == "TRANSCRIPTION":
|
||||||
USER_MENU_SHOWN[user_id] = True
|
USER_MENU_SHOWN[user_id] = True
|
||||||
@@ -208,6 +145,18 @@ async def main_menu(msg: Message):
|
|||||||
from lk.handlers import send_dynamic_lk_main_menu
|
from lk.handlers import send_dynamic_lk_main_menu
|
||||||
await send_dynamic_lk_main_menu(msg, user_id)
|
await send_dynamic_lk_main_menu(msg, user_id)
|
||||||
return
|
return
|
||||||
|
elif action == "INSTRUCT":
|
||||||
|
USER_MENU_SHOWN[user_id] = True
|
||||||
|
set_state(user_id, "INSTRUCT_MODE")
|
||||||
|
log_menu_stats(user_id, "Инструкции", "Вход")
|
||||||
|
await msg.answer(
|
||||||
|
"📋 <b>Выберите инструкцию:</b>\n\n"
|
||||||
|
"1⃣ — 🖥 Trueconf\n"
|
||||||
|
"2⃣ — 📧 Почта\n\n"
|
||||||
|
"<i>0⃣ — В главное меню</i>",
|
||||||
|
parse_mode="html"
|
||||||
|
)
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
if not menu_shown:
|
if not menu_shown:
|
||||||
USER_MENU_SHOWN[user_id] = True
|
USER_MENU_SHOWN[user_id] = True
|
||||||
@@ -225,6 +174,7 @@ async def main():
|
|||||||
dp.include_router(sd_router)
|
dp.include_router(sd_router)
|
||||||
dp.include_router(transcription_router)
|
dp.include_router(transcription_router)
|
||||||
dp.include_router(search_router)
|
dp.include_router(search_router)
|
||||||
|
dp.include_router(instruct_router)
|
||||||
dp.include_router(lk_router)
|
dp.include_router(lk_router)
|
||||||
dp.include_router(main_router)
|
dp.include_router(main_router)
|
||||||
bot = Bot.from_credentials(server=TC_SERVER, username=TC_LOGIN, password=TC_PASSWORD, dispatcher=dp, verify_ssl=False)
|
bot = Bot.from_credentials(server=TC_SERVER, username=TC_LOGIN, password=TC_PASSWORD, dispatcher=dp, verify_ssl=False)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15
-13
@@ -5,37 +5,39 @@ from ldap3 import Server, Connection, ALL, MODIFY_REPLACE
|
|||||||
|
|
||||||
# Подтягиваем конфиг
|
# Подтягиваем конфиг
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASE
|
from config.config import AD_SERVER, AD_USER, AD_PASSWORD
|
||||||
|
from utils.ad_search import search_by_login as ad_search_all_bases
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def upload_photo_to_ad(target_user: str, photo_path: str) -> dict:
|
def upload_photo_to_ad(target_user: str, photo_path: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Читает готовое фото с диска и записывает его в атрибут thumbnailPhoto в AD.
|
Читает готовое фото с диска и записывает его в атрибут thumbnailPhoto в AD.
|
||||||
|
Ищет пользователя по всем OU из AD_BASES.
|
||||||
"""
|
"""
|
||||||
|
from utils.ad_search import search_by_filter
|
||||||
try:
|
try:
|
||||||
# Читаем сырые байты картинки
|
# Читаем сырые байты картинки
|
||||||
with open(photo_path, "rb") as f:
|
with open(photo_path, "rb") as f:
|
||||||
photo_bytes = f.read()
|
photo_bytes = f.read()
|
||||||
|
|
||||||
# Формируем фильтр для поиска
|
# Ищем пользователя в AD по всем OU
|
||||||
if '@' in target_user:
|
if '@' in target_user:
|
||||||
ldap_filter = f"(mail={target_user})"
|
ldap_filter = "(mail={})".format(target_user)
|
||||||
else:
|
else:
|
||||||
ldap_filter = f"(sAMAccountName={target_user})"
|
ldap_filter = "(sAMAccountName={})".format(target_user)
|
||||||
|
|
||||||
# Подключаемся к AD
|
entries = search_by_filter(ldap_filter, ["cn"])
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
return {"success": False, "error": "Пользователь {} не найден в AD.".format(target_user)}
|
||||||
|
|
||||||
|
user_dn = entries[0].entry_dn
|
||||||
|
|
||||||
|
# Подключаемся для модификации
|
||||||
server = Server(AD_SERVER, get_info=ALL)
|
server = Server(AD_SERVER, get_info=ALL)
|
||||||
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
|
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
|
||||||
|
|
||||||
# Находим пользователя в дереве (нам нужен его точный путь - DN)
|
|
||||||
conn.search(search_base=AD_BASE, search_filter=ldap_filter, attributes=["cn"])
|
|
||||||
|
|
||||||
if not conn.entries:
|
|
||||||
return {"success": False, "error": f"Пользователь {target_user} не найден в AD."}
|
|
||||||
|
|
||||||
user_dn = conn.entries[0].entry_dn
|
|
||||||
|
|
||||||
# 🪄 МАГИЯ: Перезаписываем атрибут thumbnailPhoto нашими байтами
|
# 🪄 МАГИЯ: Перезаписываем атрибут thumbnailPhoto нашими байтами
|
||||||
conn.modify(user_dn, {'thumbnailPhoto': [(MODIFY_REPLACE, [photo_bytes])]})
|
conn.modify(user_dn, {'thumbnailPhoto': [(MODIFY_REPLACE, [photo_bytes])]})
|
||||||
|
|
||||||
|
|||||||
+143
-58
@@ -2,6 +2,7 @@ import os
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
import io
|
||||||
|
|
||||||
# Добавляем путь к корню проекта
|
# Добавляем путь к корню проекта
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
@@ -10,8 +11,21 @@ from trueconf import Bot, Dispatcher, Router, Message
|
|||||||
from trueconf.types import FSInputFile
|
from trueconf.types import FSInputFile
|
||||||
|
|
||||||
# Наши обработчики
|
# Наши обработчики
|
||||||
from photo_processor import prepare_ad_photo
|
from photo_processor import prepare_ad_photo, get_crop_coordinates
|
||||||
from ad_helper import upload_photo_to_ad
|
from utils.texts import (
|
||||||
|
PHOTO_MAIN_MENU_TEXT,
|
||||||
|
PHOTO_UNKNOWN_CMD_TEXT,
|
||||||
|
PHOTO_PROCESSING_FACE_TEXT,
|
||||||
|
PHOTO_UPLOADING_API_TEXT,
|
||||||
|
photo_preview_ready,
|
||||||
|
photo_success_published,
|
||||||
|
photo_success_pending,
|
||||||
|
photo_error_rejected,
|
||||||
|
photo_error_already_pending,
|
||||||
|
photo_cancel_text,
|
||||||
|
photo_error_handling,
|
||||||
|
EMOJI_DIGITS,
|
||||||
|
)
|
||||||
|
|
||||||
# Настройки
|
# Настройки
|
||||||
from config.config import *
|
from config.config import *
|
||||||
@@ -31,9 +45,38 @@ router = Router()
|
|||||||
tc_bot = None
|
tc_bot = None
|
||||||
|
|
||||||
# 🧠 ПАМЯТЬ БОТА: Храним пути к фото, ожидающим подтверждения
|
# 🧠 ПАМЯТЬ БОТА: Храним пути к фото, ожидающим подтверждения
|
||||||
# Пример: {'ds.krivochenko': '/tmp/ready_ds.krivochenko.jpg'}
|
# Пример: {'ds.krivochenko': {'path': '/tmp/orig_abc.jpg', 'coords': (100, 50, 300, 400)}}
|
||||||
pending_photos = {}
|
pending_photos = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_preview(image_bytes: bytes, coords: tuple, size: int = 400) -> bytes | None:
|
||||||
|
"""Генерирует превью фото с кропом по координатам."""
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.open(io.BytesIO(image_bytes))
|
||||||
|
if img.mode != 'RGB':
|
||||||
|
img = img.convert('RGB')
|
||||||
|
|
||||||
|
x1, y1, x2, y2 = coords
|
||||||
|
cropped = img.crop((x1, y1, x2, y2))
|
||||||
|
|
||||||
|
# Ресайз до квадрата
|
||||||
|
side = min(cropped.size[0], cropped.size[1])
|
||||||
|
left = (cropped.size[0] - side) // 2
|
||||||
|
top = (cropped.size[1] - side) // 2
|
||||||
|
cropped = cropped.crop((left, top, left + side, top + side))
|
||||||
|
|
||||||
|
preview = cropped.resize((size, size), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
preview.save(buf, format="JPEG", quality=85)
|
||||||
|
buf.seek(0)
|
||||||
|
return buf.getvalue()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка генерации превью: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.message()
|
@router.message()
|
||||||
async def handle_message(msg: Message):
|
async def handle_message(msg: Message):
|
||||||
global tc_bot
|
global tc_bot
|
||||||
@@ -52,39 +95,67 @@ async def handle_message(msg: Message):
|
|||||||
|
|
||||||
# Если согласен
|
# Если согласен
|
||||||
if answer in ["да", "yes", "+", "загрузить", "давай", "конечно"]:
|
if answer in ["да", "yes", "+", "загрузить", "давай", "конечно"]:
|
||||||
photo_path = pending_photos[user_login]
|
photo_data = pending_photos[user_login]
|
||||||
await msg.answer("⏳ Подключаюсь к Active Directory и загружаю фото...")
|
original_path = photo_data['path']
|
||||||
|
coords = photo_data['coords']
|
||||||
|
|
||||||
# Запускаем скрипт загрузки в AD
|
await msg.answer(PHOTO_UPLOADING_API_TEXT)
|
||||||
upload_result = await asyncio.to_thread(upload_photo_to_ad, user_login, photo_path)
|
|
||||||
|
|
||||||
if upload_result["success"]:
|
try:
|
||||||
await msg.answer(
|
# Отправляем фото через API
|
||||||
"🎉 **Ура! Фото успешно установлено в Active Directory!**\n\n"
|
result = await asyncio.to_thread(
|
||||||
"Оно появится в Outlook, TrueConf и других системах в течение нескольких часов (после синхронизации серверов)."
|
prepare_ad_photo, original_path, user_login
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
await msg.answer(f"❌ **Ошибка при загрузке:** {upload_result.get('error')}")
|
|
||||||
|
|
||||||
# Убираем за собой файл и стираем пользователя из памяти
|
if result["success"]:
|
||||||
if os.path.exists(photo_path):
|
verdict = result.get("verdict")
|
||||||
os.remove(photo_path)
|
if verdict == "published":
|
||||||
|
await msg.answer(photo_success_published())
|
||||||
|
elif verdict == "pending_moderation":
|
||||||
|
await msg.answer(photo_success_pending())
|
||||||
|
else:
|
||||||
|
# Успех, но вердикт другой — показываем сообщение
|
||||||
|
msg_text = result.get("message", "Фото обработано")
|
||||||
|
await msg.answer(f"✅ {msg_text}")
|
||||||
|
else:
|
||||||
|
error = result.get("error", "Неизвестная ошибка")
|
||||||
|
if "уже есть фото" in error:
|
||||||
|
await msg.answer(photo_error_already_pending())
|
||||||
|
elif "отклонено" in error.lower() or "не прошел" in error.lower():
|
||||||
|
await msg.answer(photo_error_rejected(error))
|
||||||
|
else:
|
||||||
|
await msg.answer(f"❌ Ошибка: {error}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка отправки фото: {e}")
|
||||||
|
await msg.answer(photo_error_handling())
|
||||||
|
|
||||||
|
# Убираем за собой
|
||||||
|
if os.path.exists(original_path):
|
||||||
|
os.remove(original_path)
|
||||||
del pending_photos[user_login]
|
del pending_photos[user_login]
|
||||||
return
|
return
|
||||||
|
|
||||||
# Если отказался
|
# Если отказался
|
||||||
elif answer in ["нет", "no", "-", "отмена"]:
|
elif answer in ["нет", "no", "-", "отмена"]:
|
||||||
photo_path = pending_photos[user_login]
|
photo_data = pending_photos[user_login]
|
||||||
await msg.answer("🚫 Загрузка отменена. Можете прислать мне другую фотографию!")
|
original_path = photo_data['path']
|
||||||
|
|
||||||
if os.path.exists(photo_path):
|
await msg.answer(photo_cancel_text())
|
||||||
os.remove(photo_path)
|
|
||||||
|
if os.path.exists(original_path):
|
||||||
|
os.remove(original_path)
|
||||||
del pending_photos[user_login]
|
del pending_photos[user_login]
|
||||||
return
|
return
|
||||||
|
|
||||||
# Если написал что-то непонятное, но мы ждем ответа
|
# Если написал что-то непонятное
|
||||||
else:
|
else:
|
||||||
await msg.answer("❓ Пожалуйста, напишите **Да** (чтобы загрузить фото в AD) или **Нет** (чтобы отменить). Либо просто пришлите новую фотографию.")
|
await msg.answer(
|
||||||
|
f"❓ Пожалуйста, нажмите:\n"
|
||||||
|
f"1️⃣ — Подтвердить\n"
|
||||||
|
f"2️⃣ — Вырезать заново\n\n"
|
||||||
|
f"Или просто отправьте новое фото."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
@@ -97,64 +168,78 @@ async def handle_message(msg: Message):
|
|||||||
await msg.answer("❌ Не удалось получить файл. Попробуйте еще раз.")
|
await msg.answer("❌ Не удалось получить файл. Попробуйте еще раз.")
|
||||||
return
|
return
|
||||||
|
|
||||||
ready_path = f"/tmp/ready_{user_login}.jpg"
|
await msg.answer(PHOTO_PROCESSING_FACE_TEXT)
|
||||||
raw_path = None
|
|
||||||
|
|
||||||
await msg.answer("⏳ Обрабатываю ваше фото, пожалуйста, подождите...")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
downloaded_file = await tc_bot.download_file_by_id(file_id)
|
downloaded_file = await tc_bot.download_file_by_id(file_id)
|
||||||
raw_path = downloaded_file
|
raw_path = downloaded_file
|
||||||
|
|
||||||
# Обрабатываем фото (с новым разрешением 240x240)
|
# Читаем байты
|
||||||
result = await asyncio.to_thread(prepare_ad_photo, raw_path, ready_path, 240)
|
with open(raw_path, 'rb') as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
|
||||||
if result["success"]:
|
# Ищем лицо и получаем координаты
|
||||||
photo_document = FSInputFile(ready_path, filename=f"AD_{user_login}.jpg")
|
coords = await asyncio.to_thread(get_crop_coordinates, image_bytes)
|
||||||
|
|
||||||
|
if not coords:
|
||||||
|
await msg.answer(
|
||||||
|
"❌ Лицо не найдено. Пожалуйста, сделайте фото анфас при хорошем освещении.\n\n"
|
||||||
|
f"{EMOJI_DIGITS['0']} — В главное меню"
|
||||||
|
)
|
||||||
|
if os.path.exists(raw_path):
|
||||||
|
os.remove(raw_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Генерируем превью
|
||||||
|
preview_bytes = _generate_preview(image_bytes, coords, size=400)
|
||||||
|
|
||||||
|
if preview_bytes:
|
||||||
|
# Сохраняем оригинал и координаты
|
||||||
|
preview_path = f"/tmp/preview_{user_login}.jpg"
|
||||||
|
with open(preview_path, 'wb') as f:
|
||||||
|
f.write(preview_bytes)
|
||||||
|
|
||||||
|
# Сохраняем оригинал
|
||||||
|
original_path = f"/tmp/orig_{user_login}.jpg"
|
||||||
|
with open(original_path, 'wb') as f:
|
||||||
|
f.write(image_bytes)
|
||||||
|
|
||||||
|
# Отправляем превью
|
||||||
|
preview_doc = FSInputFile(preview_path, filename=f"preview_{user_login}.jpg")
|
||||||
|
|
||||||
# Отправляем фото и СРАЗУ задаем вопрос
|
|
||||||
await msg.answer_photo(
|
await msg.answer_photo(
|
||||||
photo_document,
|
preview_doc, preview_doc,
|
||||||
photo_document,
|
caption=photo_preview_ready()
|
||||||
caption=(
|
|
||||||
f"✅ **Фото готово!**\n\n"
|
|
||||||
f"📐 Размер: 240x240 px\n"
|
|
||||||
f"⚖️ Вес: {result['size_kb']} KB\n\n"
|
|
||||||
f"❓ **Вам нравится результат? Загружаем в Active Directory?**\n"
|
|
||||||
f"Напишите мне в ответ **Да** или **Нет**."
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 🧠 ЗАПОМИНАЕМ, что этот пользователь теперь в режиме ожидания ответа
|
# Сохраняем состояние
|
||||||
pending_photos[user_login] = ready_path
|
pending_photos[user_login] = {
|
||||||
logger.info(f"Фото подготовлено для {user_login}, ждем подтверждения.")
|
'path': original_path,
|
||||||
|
'coords': coords,
|
||||||
|
'preview_path': preview_path
|
||||||
|
}
|
||||||
|
logger.info(f"Фото подготовлено для {user_login}, координаты: {coords}")
|
||||||
else:
|
else:
|
||||||
await msg.answer(f"❌ Ошибка обработки: {result.get('error')}")
|
await msg.answer("❌ Ошибка обработки фото. Попробуйте снова.")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при работе с фото для {user_login}: {e}")
|
logger.error(f"Ошибка при работе с фото для {user_login}: {e}")
|
||||||
await msg.answer("❌ Произошла системная ошибка при обработке.")
|
await msg.answer(photo_error_handling())
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# ВАЖНО: Удаляем ТОЛЬКО оригинал от TrueConf (raw_path).
|
# Удаляем оригинал TrueConf, но не превью (оно в pending_photos)
|
||||||
# Готовое фото (ready_path) оставляем на диске, оно ждет ответа "Да"!
|
if 'raw_path' in locals() and os.path.exists(raw_path):
|
||||||
if raw_path and os.path.exists(raw_path):
|
try:
|
||||||
try: os.remove(raw_path)
|
os.remove(raw_path)
|
||||||
except: pass
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# СЦЕНАРИЙ 3: Дефолтное приветствие (если просто написали текст)
|
# СЦЕНАРИЙ 3: Дефолтное приветствие (если просто написали текст)
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
await msg.answer(
|
await msg.answer(PHOTO_MAIN_MENU_TEXT)
|
||||||
"👋 Привет! Я корпоративный фотограф для Active Directory.\n\n"
|
|
||||||
"📸 **Просто отправь мне фото**, и я автоматически:\n"
|
|
||||||
"1. Найду лицо на снимке и сделаю правильный кадр.\n"
|
|
||||||
"2. Улучшу резкость и сожму до стандарта 240x240 px.\n"
|
|
||||||
"3. Предложу загрузить результат прямо в твой профиль!"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
global tc_bot
|
global tc_bot
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from PIL import Image
|
|||||||
|
|
||||||
# Подтягиваем ваши настройки из конфига
|
# Подтягиваем ваши настройки из конфига
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASE
|
from config.config import AD_SERVER, AD_USER, AD_PASSWORD
|
||||||
|
from utils.ad_search import search_by_filter as ad_search_all_bases
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
|
||||||
@@ -21,22 +22,14 @@ def check_user_photo(target_user: str):
|
|||||||
ldap_filter = f"(sAMAccountName={target_user})"
|
ldap_filter = f"(sAMAccountName={target_user})"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Подключаемся к AD
|
# Подключаемся к AD и ищем по всем OU из AD_BASES
|
||||||
server = Server(AD_SERVER, get_info=ALL)
|
entries = ad_search_all_bases(ldap_filter, ["cn", "thumbnailPhoto"])
|
||||||
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
|
|
||||||
|
|
||||||
# Ищем пользователя и просим вернуть атрибуты: имя (cn) и фото (thumbnailPhoto)
|
if not entries:
|
||||||
conn.search(
|
|
||||||
search_base=AD_BASE,
|
|
||||||
search_filter=ldap_filter,
|
|
||||||
attributes=["cn", "thumbnailPhoto"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if not conn.entries:
|
|
||||||
print("❌ Пользователь с такими данными не найден в AD.")
|
print("❌ Пользователь с такими данными не найден в AD.")
|
||||||
return
|
return
|
||||||
|
|
||||||
user = conn.entries[0]
|
user = entries[0]
|
||||||
user_name = user.cn.value if 'cn' in user else target_user
|
user_name = user.cn.value if 'cn' in user else target_user
|
||||||
print(f"👤 Найден сотрудник: {user_name}")
|
print(f"👤 Найден сотрудник: {user_name}")
|
||||||
|
|
||||||
|
|||||||
+410
-72
@@ -2,7 +2,6 @@
|
|||||||
import os
|
import os
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
|
||||||
import smtplib
|
import smtplib
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -15,18 +14,27 @@ from trueconf.types import FSInputFile
|
|||||||
import config.config as config
|
import config.config as config
|
||||||
from utils import states
|
from utils import states
|
||||||
from utils.menu import MENU_TEXT
|
from utils.menu import MENU_TEXT
|
||||||
from photo_bot.photo_processor import prepare_ad_photo
|
from photo_bot.photo_processor import prepare_ad_photo, get_crop_coordinates, check_photo_api, make_face_crop_bytes
|
||||||
from utils.texts import (
|
from utils.texts import (
|
||||||
|
UNKNOWN_MAIN_CMD_TEXT,
|
||||||
EMOJI_DIGITS,
|
EMOJI_DIGITS,
|
||||||
PHOTO_MAIN_MENU_TEXT,
|
PHOTO_MAIN_MENU_TEXT,
|
||||||
PHOTO_UNKNOWN_CMD_TEXT,
|
|
||||||
photo_error_handling,
|
photo_error_handling,
|
||||||
PHOTO_UPLOADING_TEXT,
|
PHOTO_PROCESSING_FACE_TEXT,
|
||||||
photo_success_sent,
|
PHOTO_AI_PROCESSING_TEXT,
|
||||||
|
photo_success_published,
|
||||||
|
photo_success_pending,
|
||||||
|
photo_error_rejected,
|
||||||
|
photo_error_already_pending,
|
||||||
photo_cancel_text,
|
photo_cancel_text,
|
||||||
PHOTO_PROCESSING_TEXT,
|
|
||||||
photo_preview_ready,
|
photo_preview_ready,
|
||||||
photo_error_processing,
|
photo_welcome_with_status,
|
||||||
|
photo_test_welcome,
|
||||||
|
PHOTO_TEST_WELCOME_TEXT,
|
||||||
|
PHOTO_TEST_PREVIEW_TEXT,
|
||||||
|
PHOTO_TEST_AI_CHECKING_TEXT,
|
||||||
|
photo_test_result,
|
||||||
|
photo_test_ai_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 🔌 Импортируем централизованную функцию сбора статистики из main
|
# 🔌 Импортируем централизованную функцию сбора статистики из main
|
||||||
@@ -36,8 +44,23 @@ logger = logging.getLogger(__name__)
|
|||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
# Словарь для временного хранения путей к фото пользователей
|
# Словарь для временного хранения путей к фото пользователей
|
||||||
|
# Формат: {user_id: {'path': '/tmp/orig_user.jpg', 'coords': (x1,y1,x2,y2)}}
|
||||||
pending_photos = {}
|
pending_photos = {}
|
||||||
|
|
||||||
|
# Пользователи, находящиеся в под-режиме «тест проверка фото»
|
||||||
|
test_submode_users = set()
|
||||||
|
|
||||||
|
# Пользователи, у которых фото на модерации (вход заблокирован,
|
||||||
|
# модуль остаётся открыт: в тест-режиме можно отправить фото на тест)
|
||||||
|
moderation_pending_users = set()
|
||||||
|
|
||||||
|
def in_test_submode(user_id: str) -> bool:
|
||||||
|
"""Проверяет, находится ли пользователь в под-режиме тест-проверки фото"""
|
||||||
|
if user_id not in test_submode_users:
|
||||||
|
return False
|
||||||
|
# Если сессия истекла (состояние сброшено) — под-режим тоже недействителен
|
||||||
|
return states.get_state(user_id) == "PHOTO_MODE"
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# СИСТЕМА ОБРАБОТКИ ОШИБОК И ЛОГИРОВАНИЯ
|
# СИСТЕМА ОБРАБОТКИ ОШИБОК И ЛОГИРОВАНИЯ
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -101,11 +124,95 @@ def log_and_notify_photo_error(user_id: str, action: str, error_details: str):
|
|||||||
|
|
||||||
async def handle_photo_error(msg: Message, user_id: str, action: str, error_details: str):
|
async def handle_photo_error(msg: Message, user_id: str, action: str, error_details: str):
|
||||||
"""Вызывает системное логирование и выдает заглушку пользователю"""
|
"""Вызывает системное логирование и выдает заглушку пользователю"""
|
||||||
|
logger.error(f"🔥 Ошибка фото-бота для {user_id}, действие: {action}: {error_details}")
|
||||||
await asyncio.to_thread(log_and_notify_photo_error, user_id, action, error_details)
|
await asyncio.to_thread(log_and_notify_photo_error, user_id, action, error_details)
|
||||||
|
|
||||||
await msg.answer(photo_error_handling(), parse_mode="html")
|
await msg.answer(photo_error_handling(), parse_mode="html")
|
||||||
|
|
||||||
|
|
||||||
|
async def check_moderation_status(user_id: str) -> dict:
|
||||||
|
"""Проверяет текущий статус модерации пользователя через API"""
|
||||||
|
from photo_bot.photo_processor import API_URL, API_KEY
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Извлекаем логин из email (user_id@tcs.sibcem.ru -> user_id)
|
||||||
|
login = user_id.split('@')[0]
|
||||||
|
|
||||||
|
status_url = API_URL.replace("/upload/", "/status/")
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
status_url,
|
||||||
|
params={"user_id": login},
|
||||||
|
headers={"X-API-Key": API_KEY},
|
||||||
|
timeout=15,
|
||||||
|
verify=False
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка проверки статуса модерации: {e}")
|
||||||
|
return {"status": "error", "reason": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# ВХОД В РАЗДЕЛ ФОТО: проверка статуса модерации + показ меню
|
||||||
|
# =========================================================
|
||||||
|
async def send_dynamic_photo_main_menu(msg: Message, user_id: str):
|
||||||
|
"""Вход в раздел фото (паттерн ЛК): модуль сам запрашивает данные и показывает их.
|
||||||
|
|
||||||
|
- pending — сообщение «фото на модерации»; модуль остаётся открыт
|
||||||
|
(цифры → ошибка неверного выбора, 0 — выход). Тест-режим:
|
||||||
|
фото можно отправить на проверку в ИИ;
|
||||||
|
- rejected — ОДНО сообщение: меню + причина отказа (кнопки на месте);
|
||||||
|
- иначе/ошибка API — штатное меню входа.
|
||||||
|
Статус проверяется и показывается всегда, включая тестовый режим.
|
||||||
|
"""
|
||||||
|
# Новый вход в модуль (кнопка 4 из главного меню) = свежий сеанс.
|
||||||
|
# Тайм-аут сессии чистит только user_states, а module-level pending_photos
|
||||||
|
# и test_submode_users остаются: висящий тест-кадр из прошлой сессии иначе
|
||||||
|
# улетит на проверку сразу после нажатия «1». Сбрасываем здесь.
|
||||||
|
if user_id in pending_photos:
|
||||||
|
_pd = pending_photos[user_id]
|
||||||
|
for _k in ('path', 'preview_path'):
|
||||||
|
_p = _pd.get(_k)
|
||||||
|
if _p and os.path.exists(_p):
|
||||||
|
try:
|
||||||
|
os.remove(_p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
del pending_photos[user_id]
|
||||||
|
test_submode_users.discard(user_id)
|
||||||
|
|
||||||
|
# Статус модерации запрашиваем всегда (и в тестовом режиме тоже)
|
||||||
|
status = await check_moderation_status(user_id)
|
||||||
|
if status.get("status") == "ok" and status.get("moderation_status") == "pending":
|
||||||
|
# Фото на модерации — штатная загрузка заблокирована,
|
||||||
|
# но модуль остаётся открыт (PHOTO_MODE), чтобы цифры не ушли в главное меню
|
||||||
|
log_menu_stats(user_id, "Photo Bot", "Вход — фото на модерации")
|
||||||
|
states.set_state(user_id, "PHOTO_MODE")
|
||||||
|
moderation_pending_users.add(user_id)
|
||||||
|
# Единый вход: «фото на модерации» (+ кнопка 1 — тест, если тест-режим включён).
|
||||||
|
# Описание тест-режима показываем ТОЛЬКО после нажатия кнопки 1
|
||||||
|
await msg.answer(
|
||||||
|
photo_welcome_with_status(status, show_test=config.PHOTO_TEST_MODE),
|
||||||
|
parse_mode="html"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if status.get("status") == "ok" and status.get("moderation_status") == "rejected":
|
||||||
|
log_menu_stats(user_id, "Photo Bot", "Вход — фото отклонено, показана причина")
|
||||||
|
elif status.get("status") != "ok":
|
||||||
|
logger.warning(
|
||||||
|
f"Не удалось проверить статус модерации для {user_id}: {status.get('reason')}"
|
||||||
|
)
|
||||||
|
status = None
|
||||||
|
|
||||||
|
states.set_state(user_id, "PHOTO_MODE")
|
||||||
|
log_menu_stats(user_id, "Photo Bot (TEST)" if config.PHOTO_TEST_MODE else "Photo Bot", "Вход")
|
||||||
|
# Одно универсальное сообщение: меню (+ тест-кнопка) + (если нужно) статус модерации
|
||||||
|
entry_text = photo_welcome_with_status(status, show_test=config.PHOTO_TEST_MODE)
|
||||||
|
await msg.answer(entry_text, parse_mode="html")
|
||||||
|
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# ОСНОВНОЙ ОБРАБОТЧИК
|
# ОСНОВНОЙ ОБРАБОТЧИК
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -131,112 +238,343 @@ async def photo_module_handler(msg: Message):
|
|||||||
cmd = raw_num
|
cmd = raw_num
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# --- ТЕСТ-ПОД-РЕЖИМ: 9 = назад в меню фото (не выход из модуля) ---
|
||||||
|
if in_test_submode(user_id) and cmd == "9" and not is_attachment:
|
||||||
|
test_submode_users.discard(user_id)
|
||||||
|
log_menu_stats(user_id, "Photo Bot (TEST)", "Назад из тест-под-режима")
|
||||||
|
# Висящее тест-фото снимаем (тест-режим: оно никуда не публиковалось)
|
||||||
|
if user_id in pending_photos:
|
||||||
|
photo_data = pending_photos[user_id]
|
||||||
|
if os.path.exists(photo_data['path']):
|
||||||
|
os.remove(photo_data['path'])
|
||||||
|
if 'preview_path' in photo_data and os.path.exists(photo_data['preview_path']):
|
||||||
|
os.remove(photo_data['preview_path'])
|
||||||
|
del pending_photos[user_id]
|
||||||
|
status = await check_moderation_status(user_id)
|
||||||
|
await msg.answer(
|
||||||
|
photo_welcome_with_status(status, show_test=config.PHOTO_TEST_MODE),
|
||||||
|
parse_mode="html"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню с очисткой кэша) ---
|
# --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню с очисткой кэша) ---
|
||||||
if cmd in ["0", "9", "/start", "меню"]:
|
if cmd in ["0", "9", "/start", "меню"]:
|
||||||
log_menu_stats(user_id, "Photo Bot", "Выход в главное меню")
|
log_menu_stats(user_id, "Photo Bot", "Выход в главное меню")
|
||||||
states.clear_state(user_id)
|
states.clear_state(user_id)
|
||||||
|
test_submode_users.discard(user_id)
|
||||||
|
moderation_pending_users.discard(user_id)
|
||||||
if user_id in pending_photos:
|
if user_id in pending_photos:
|
||||||
if os.path.exists(pending_photos[user_id]):
|
photo_data = pending_photos[user_id]
|
||||||
os.remove(pending_photos[user_id])
|
if os.path.exists(photo_data['path']):
|
||||||
|
os.remove(photo_data['path'])
|
||||||
|
if 'preview_path' in photo_data and os.path.exists(photo_data['preview_path']):
|
||||||
|
os.remove(photo_data['preview_path'])
|
||||||
del pending_photos[user_id]
|
del pending_photos[user_id]
|
||||||
|
|
||||||
await msg.answer(MENU_TEXT, parse_mode="html")
|
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- ПОДТВЕРЖДЕНИЕ ОТПРАВКИ (Кнопка 1 или 2) ---
|
# --- СОСТОЯНИЕ «ФОТО НА МОДЕРАЦИИ»: активного фото нет ---
|
||||||
if not is_attachment and user_id in pending_photos:
|
if user_id in moderation_pending_users and user_id not in pending_photos:
|
||||||
if cmd == "1":
|
if not is_attachment:
|
||||||
photo_path = pending_photos[user_id]
|
status = await check_moderation_status(user_id)
|
||||||
final_filename = f"{user_login}.bmp"
|
still_pending = (
|
||||||
|
status.get("status") == "ok"
|
||||||
|
and status.get("moderation_status") == "pending"
|
||||||
|
)
|
||||||
|
if still_pending:
|
||||||
|
if config.PHOTO_TEST_MODE and cmd == "1":
|
||||||
|
# Кнопка 1 — вход в тест-под-режим (можно отправить фото в ИИ)
|
||||||
|
test_submode_users.add(user_id)
|
||||||
|
log_menu_stats(user_id, "Photo Bot (TEST)", "Тест-под-режим (фото на модерации)")
|
||||||
|
await msg.answer(PHOTO_TEST_WELCOME_TEXT, parse_mode="html")
|
||||||
|
else:
|
||||||
|
# Неверная команда → глобальное сообщение
|
||||||
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
return
|
||||||
|
# Статус изменился (одобрено/отклонено) — разблокируем
|
||||||
|
was_in_test = in_test_submode(user_id)
|
||||||
|
moderation_pending_users.discard(user_id)
|
||||||
|
if config.PHOTO_TEST_MODE and was_in_test:
|
||||||
|
await msg.answer(PHOTO_TEST_WELCOME_TEXT, parse_mode="html")
|
||||||
|
else:
|
||||||
|
await msg.answer(
|
||||||
|
photo_welcome_with_status(status), parse_mode="html"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# is_attachment — пропускаем: фото обрабатывается штатно ниже
|
||||||
|
# (тест-под-режим: превью + отправка в ИИ)
|
||||||
|
|
||||||
await msg.answer(PHOTO_UPLOADING_TEXT, parse_mode="html")
|
# --- ПРОВЕРКА СТАТУСА: если нет фото в pending_photos, проверяем статус модерации ---
|
||||||
|
# (в тестовом режиме статусы тоже показываем — поведение как в обычном режиме)
|
||||||
|
if user_id not in pending_photos and not is_attachment:
|
||||||
|
status = await check_moderation_status(user_id)
|
||||||
|
if status.get("status") == "ok" and status.get("moderation_status") == "pending":
|
||||||
|
await msg.answer(
|
||||||
|
photo_welcome_with_status(status, show_test=config.PHOTO_TEST_MODE),
|
||||||
|
parse_mode="html"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
elif status.get("status") != "ok":
|
||||||
|
# Ошибка проверки — не блокируем, просто логируем
|
||||||
|
logger.warning(f"Не удалось проверить статус модерации для {user_id}: {status.get('reason')}")
|
||||||
|
|
||||||
|
# --- ВХОД В ТЕСТ-ПОД-РЕЖИМ: кнопка 1 в меню фото-режима (тест включен) ---
|
||||||
|
if (not is_attachment and user_id not in pending_photos
|
||||||
|
and not in_test_submode(user_id)
|
||||||
|
and config.PHOTO_TEST_MODE and cmd == "1"):
|
||||||
|
test_submode_users.add(user_id)
|
||||||
|
log_menu_stats(user_id, "Photo Bot (TEST)", "Вход в тест-под-режим")
|
||||||
|
await msg.answer(PHOTO_TEST_WELCOME_TEXT, parse_mode="html")
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- ТЕСТ-ПОД-РЕЖИМ: кнопка 1 отправляет фото НАПРЯМУЮ в ИИ, фото никуда не публикуется ---
|
||||||
|
if not is_attachment and user_id in pending_photos and in_test_submode(user_id):
|
||||||
|
if cmd == "1":
|
||||||
|
photo_data = pending_photos[user_id]
|
||||||
|
original_path = photo_data['path']
|
||||||
|
|
||||||
|
await msg.answer(PHOTO_TEST_AI_CHECKING_TEXT, parse_mode="html")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
with open(original_path, 'rb') as f:
|
||||||
with open(photo_path, "rb") as f:
|
raw_photo = f.read()
|
||||||
files = {'file': (final_filename, f, 'image/bmp')}
|
# На проверку уходит АВТО-КРОП ЛИЦА (тот же, что в превью
|
||||||
response = await client.post(config.TRASH_API_URL, files=files)
|
# и что публикуется на сайте) — правила «голова ≥ 30%» и
|
||||||
|
# прочее имеют смысл для публикуемого результата, а не
|
||||||
if response.status_code == 200:
|
# для произвольно снятого оригинала.
|
||||||
# ФИКСАЦИЯ СТАТИСТИКИ: Успешная выгрузка фото на утверждение
|
crop_bytes = await asyncio.to_thread(
|
||||||
log_menu_stats(user_id, "Photo Bot", "Отправка фото на утверждение")
|
make_face_crop_bytes, raw_photo, photo_data.get('coords')
|
||||||
await msg.answer(photo_success_sent(), parse_mode="html")
|
)
|
||||||
|
# Dry-run: полный пайплайн проверки на САЙТЕ (CV + гримасы + ИИ),
|
||||||
states.clear_state(user_id)
|
# фото в БД/AD/Orion не попадает
|
||||||
if os.path.exists(photo_path):
|
check_res = await asyncio.to_thread(check_photo_api, crop_bytes)
|
||||||
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)
|
|
||||||
except Exception as e:
|
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
|
return
|
||||||
|
|
||||||
# --- ОТМЕНА ФОТО (Кнопка 2) ---
|
# --- ОТМЕНА ФОТО (Кнопка 2) ---
|
||||||
elif cmd == "2":
|
elif cmd == "2":
|
||||||
log_menu_stats(user_id, "Photo Bot", "Отмена загруженного фото")
|
log_menu_stats(user_id, "Photo Bot", "Отмена загруженного фото")
|
||||||
photo_path = pending_photos[user_id]
|
photo_data = pending_photos[user_id]
|
||||||
if os.path.exists(photo_path):
|
original_path = photo_data['path']
|
||||||
os.remove(photo_path)
|
if os.path.exists(original_path):
|
||||||
|
os.remove(original_path)
|
||||||
|
if 'preview_path' in photo_data and os.path.exists(photo_data['preview_path']):
|
||||||
|
os.remove(photo_data['preview_path'])
|
||||||
del pending_photos[user_id]
|
del pending_photos[user_id]
|
||||||
|
states.clear_state(user_id)
|
||||||
await msg.answer(photo_cancel_text(), parse_mode="html")
|
await msg.answer(photo_cancel_text(), parse_mode="html")
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- ИСПРАВЛЕНО: Защита от мусорного ввода при висящем превью ---
|
# --- ИСПРАВЛЕНО: Защита от мусорного ввода при висящем превью ---
|
||||||
else:
|
else:
|
||||||
await msg.answer(PHOTO_UNKNOWN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# --- НЕВЕРНАЯ КОМАНДА (висящего фото нет) → глобальное сообщение ---
|
||||||
|
if not is_attachment:
|
||||||
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- ФОТО НА МОДЕРАЦИИ: в обычном режиме новое фото не принимается ---
|
||||||
|
if is_attachment and not in_test_submode(user_id) and user_id in moderation_pending_users:
|
||||||
|
status = await check_moderation_status(user_id)
|
||||||
|
if status.get("status") == "ok" and status.get("moderation_status") == "pending":
|
||||||
|
log_menu_stats(user_id, "Photo Bot", "Отказ в приёме фото — есть фото на модерации")
|
||||||
|
await msg.answer(
|
||||||
|
photo_welcome_with_status(status, show_test=config.PHOTO_TEST_MODE),
|
||||||
|
parse_mode="html"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# Статус изменился (одобрено/отклонено) — снимаем блок, фото принимается штатно
|
||||||
|
moderation_pending_users.discard(user_id)
|
||||||
|
|
||||||
# --- ПРИЕМ НОВОГО ФОТО ---
|
# --- ПРИЕМ НОВОГО ФОТО ---
|
||||||
if is_attachment:
|
if is_attachment:
|
||||||
file_id = getattr(msg.content, "file_id", None)
|
file_id = getattr(msg.content, "file_id", None)
|
||||||
ready_path = f"/tmp/{user_login}.bmp"
|
raw_bytes = None
|
||||||
raw_path = None
|
|
||||||
|
|
||||||
await msg.answer(PHOTO_PROCESSING_TEXT, parse_mode="html")
|
await msg.answer(PHOTO_PROCESSING_FACE_TEXT, parse_mode="html")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
downloaded_file = await states.tc_bot.download_file_by_id(file_id)
|
downloaded_file = await states.tc_bot.download_file_by_id(file_id)
|
||||||
raw_path = downloaded_file
|
logger.info(f"Скачан файл для {user_login}: type={type(downloaded_file)}, len={len(downloaded_file) if isinstance(downloaded_file, (bytes, str)) else 'N/A'}")
|
||||||
result = await asyncio.to_thread(prepare_ad_photo, raw_path, ready_path, 96)
|
|
||||||
|
# Скачиваем в байты напрямую
|
||||||
|
if 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"
|
preview_path = f"/tmp/prev_{user_login}.jpg"
|
||||||
try:
|
preview.save(preview_path, format="JPEG", quality=85)
|
||||||
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)
|
|
||||||
|
|
||||||
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(
|
await msg.answer(
|
||||||
photo_preview_ready(),
|
photo_preview_ready(),
|
||||||
parse_mode="html"
|
parse_mode="html"
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
error_text = result.get('error', 'Не удалось обработать фото.')
|
|
||||||
await msg.answer(
|
|
||||||
photo_error_processing(error_text),
|
|
||||||
parse_mode="html"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error(f"🔥 Полная ошибка: {e}", exc_info=True)
|
||||||
await handle_photo_error(msg, user_id, "Скачивание и обработка файла", str(e))
|
await handle_photo_error(msg, user_id, "Скачивание и обработка файла", str(e))
|
||||||
finally:
|
|
||||||
if raw_path and os.path.exists(raw_path):
|
|
||||||
try:
|
|
||||||
os.remove(raw_path)
|
|
||||||
except Exception as cleanup_err:
|
|
||||||
await asyncio.to_thread(log_and_notify_photo_error, user_id, "Очистка временных файлов", str(cleanup_err))
|
|
||||||
return
|
return
|
||||||
|
|||||||
+217
-88
@@ -1,84 +1,65 @@
|
|||||||
import io
|
import io
|
||||||
import cv2
|
import cv2
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image, ImageFilter, ImageEnhance
|
import requests
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def prepare_ad_photo(input_path: str, output_path: str, target_size: int = 96) -> dict:
|
# Настройки API
|
||||||
|
API_URL = "https://profile.sibcem.ru:8085/SibCemProfile/api/photo-bot/upload/"
|
||||||
|
API_KEY = "sibcem_bot_2026_a7f3k9m2x8q1w5e4r6t"
|
||||||
|
|
||||||
|
|
||||||
|
def _image_to_cv2(image_bytes: bytes):
|
||||||
|
"""Конвертирует байты изображения в OpenCV матрицу (поддержка webp)."""
|
||||||
|
# Сначала пробуем cv2.imdecode
|
||||||
|
nparr = np.frombuffer(image_bytes, np.uint8)
|
||||||
|
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||||
|
if img is not None:
|
||||||
|
return img
|
||||||
|
|
||||||
|
# Если не сработало, пробуем через PIL (для webp и других форматов)
|
||||||
try:
|
try:
|
||||||
# 1. Загружаем изображение для OpenCV
|
pil_img = Image.open(io.BytesIO(image_bytes))
|
||||||
if isinstance(input_path, bytes):
|
if pil_img.mode != 'RGB':
|
||||||
img = cv2.imdecode(np.frombuffer(input_path, np.uint8), cv2.IMREAD_COLOR)
|
pil_img = pil_img.convert('RGB')
|
||||||
else:
|
img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
||||||
img = cv2.imread(input_path)
|
return img
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Не удалось открыть изображение: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_crop_coordinates(image_bytes: bytes) -> tuple | None:
|
||||||
|
"""
|
||||||
|
Ищет лицо на фото и возвращает координаты квадратного кропа.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(crop_x1, crop_y1, crop_x2, crop_y2) или None если лицо не найдено.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
img = _image_to_cv2(image_bytes)
|
||||||
if img is None:
|
if img is None:
|
||||||
return {"success": False, "error": "Не удалось прочитать файл изображения."}
|
logger.warning("Не удалось декодировать изображение")
|
||||||
if isinstance(input_path, bytes):
|
return None
|
||||||
img = cv2.imdecode(np.frombuffer(input_path, np.uint8), cv2.IMREAD_COLOR)
|
|
||||||
else:
|
|
||||||
img = cv2.imread(input_path)
|
|
||||||
if img is None:
|
|
||||||
return {"success": False, "error": "Не удалось прочитать файл изображения."}
|
|
||||||
|
|
||||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
# 2. Загружаем каскад Хаара для поиска лица
|
|
||||||
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
||||||
face_cascade = cv2.CascadeClassifier(cascade_path)
|
face_cascade = cv2.CascadeClassifier(cascade_path)
|
||||||
|
|
||||||
# 3. Ищем лица
|
|
||||||
# Оставляем оптимальное значение minNeighbors=8 для баланса
|
|
||||||
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=8, minSize=(50, 50))
|
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=8, minSize=(50, 50))
|
||||||
|
|
||||||
if len(faces) == 0:
|
if len(faces) == 0:
|
||||||
return {"success": False, "error": "Лицо не найдено! Пожалуйста, сделайте фото анфас при хорошем освещении."}
|
logger.warning("Лицо не найдено на фото")
|
||||||
|
return None
|
||||||
|
|
||||||
if len(faces) > 1:
|
if len(faces) > 1:
|
||||||
faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True)
|
faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True)
|
||||||
|
|
||||||
x, y, w, h = faces[0]
|
x, y, w, h = faces[0]
|
||||||
|
|
||||||
# =========================================================
|
|
||||||
# АБСОЛЮТНАЯ ЗАЩИТА ОТ ГРАФИКИ (НОРМАЛИЗОВАННАЯ МАТЕМАТИКА)
|
|
||||||
# =========================================================
|
|
||||||
# 1. Вырезаем центральную зону лица
|
|
||||||
face_roi_original = gray[y + int(h*0.2):y + int(h*0.8), x + int(w*0.2):x + int(w*0.8)]
|
|
||||||
|
|
||||||
# 2. Сжимаем до 100x100 пикселей.
|
|
||||||
# Это уравнивает 4K-фотографии и мелкие картинки, делая метрики стабильными.
|
|
||||||
face_roi = cv2.resize(face_roi_original, (100, 100))
|
|
||||||
|
|
||||||
std_dev = np.std(face_roi)
|
|
||||||
laplacian_var = cv2.Laplacian(face_roi, cv2.CV_64F).var()
|
|
||||||
unique_colors = len(np.unique(face_roi))
|
|
||||||
|
|
||||||
logger.info(f"Анализ нормализованного лица: std_dev={std_dev:.2f}, laplacian={laplacian_var:.2f}, colors={unique_colors}")
|
|
||||||
|
|
||||||
# ПРОВЕРКА 1: Плоские, замыленные картинки
|
|
||||||
if std_dev < 10.0 or laplacian_var < 30.0:
|
|
||||||
logger.warning(f"Заблокирован плоский рисунок/замыливание: std={std_dev:.1f}, lap={laplacian_var:.1f}")
|
|
||||||
return {"success": False, "error": "Изображение слишком размыто или имеет плоскую текстуру. Отправьте реальное фото."}
|
|
||||||
|
|
||||||
# ПРОВЕРКА 2: Векторы, комиксы и жесткий 2D-арт (Умный порог резкости + цвета)
|
|
||||||
# У реального человека на хорошую камеру laplacian может доходить до 500-600,
|
|
||||||
# но при этом всегда будет богатая палитра цветов (шум матрицы).
|
|
||||||
# У мультиков высокая резкость всегда сопровождается бедной палитрой.
|
|
||||||
if laplacian_var > 850.0 or (laplacian_var > 400.0 and unique_colors < 150):
|
|
||||||
logger.warning(f"Заблокирован резкий 2D-арт: lap={laplacian_var:.1f}, colors={unique_colors}")
|
|
||||||
return {"success": False, "error": "Изображение похоже на графический арт или сильно отфильтровано. Пожалуйста, загрузите естественное фото."}
|
|
||||||
|
|
||||||
# ПРОВЕРКА 3: 3D-рендеры и нейросети (Мало шума)
|
|
||||||
# На реальной фотографии (даже с хорошим светом) шум матрицы телефона
|
|
||||||
# даст минимум 110-120 уникальных оттенков на 10000 пикселей.
|
|
||||||
if unique_colors < 105:
|
|
||||||
logger.warning(f"Заблокирован 3D-рендер по палитре: unique_colors={unique_colors}")
|
|
||||||
return {"success": False, "error": "Изображение распознано как 3D-модель или рисунок (неестественная палитра). Ждем реальное фото!"}
|
|
||||||
# =========================================================
|
|
||||||
|
|
||||||
# 4. Кадрируем (делаем квадрат с отступами вокруг лица)
|
|
||||||
margin = int(w * 0.3)
|
margin = int(w * 0.3)
|
||||||
y1 = max(0, y - int(margin * 1.5))
|
y1 = max(0, y - int(margin * 1.5))
|
||||||
y2 = min(img.shape[0], y + h + margin)
|
y2 = min(img.shape[0], y + h + margin)
|
||||||
@@ -92,36 +73,184 @@ def prepare_ad_photo(input_path: str, output_path: str, target_size: int = 96) -
|
|||||||
crop_x2 = crop_x1 + side
|
crop_x2 = crop_x1 + side
|
||||||
crop_y2 = crop_y1 + side
|
crop_y2 = crop_y1 + side
|
||||||
|
|
||||||
# 5. Обработка через Pillow
|
logger.info(f"Лицо найдено: ({x},{y},{w},{h}), кроп: ({crop_x1},{crop_y1},{crop_x2},{crop_y2})")
|
||||||
pil_img = Image.open(io.BytesIO(input_path))
|
return (crop_x1, crop_y1, crop_x2, crop_y2)
|
||||||
|
|
||||||
# ВАЖНО: Убираем альфа-канал (прозрачность), иначе BMP выдаст ошибку
|
|
||||||
if pil_img.mode in ('RGBA', 'P', 'LA'):
|
|
||||||
background = Image.new('RGB', pil_img.size, (255, 255, 255))
|
|
||||||
background.paste(pil_img, mask=pil_img.split()[3] if pil_img.mode == 'RGBA' else None)
|
|
||||||
pil_img = background
|
|
||||||
elif pil_img.mode != 'RGB':
|
|
||||||
pil_img = pil_img.convert('RGB')
|
|
||||||
|
|
||||||
cropped_img = pil_img.crop((crop_x1, crop_y1, crop_x2, crop_y2))
|
|
||||||
|
|
||||||
# Сжимаем до нужного размера (96x96)
|
|
||||||
final_img = cropped_img.resize((target_size, target_size), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
# Добавляем немного резкости, чтобы лицо не мылилось при 96px
|
|
||||||
final_img = final_img.filter(ImageFilter.UnsharpMask(radius=1.5, percent=100, threshold=3))
|
|
||||||
|
|
||||||
# 6. Сохраняем в формате BMP
|
|
||||||
final_img.save(output_path, format="BMP")
|
|
||||||
|
|
||||||
# 7. Проверяем финальный вес
|
|
||||||
size_kb = os.path.getsize(output_path) / 1024
|
|
||||||
|
|
||||||
if size_kb > 99:
|
|
||||||
return {"success": False, "error": f"Сбой: размер файла ({size_kb:.1f} KB) превысил лимит в 99 KB."}
|
|
||||||
|
|
||||||
return {"success": True, "size_kb": round(size_kb, 1)}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка в photo_processor: {e}")
|
logger.error(f"Ошибка в get_crop_coordinates: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _check_moderation_status(user_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Проверяет текущий статус модерации пользователя.
|
||||||
|
Возвращает {'status': 'ok', 'moderation_status': 'pending'|'published'|'none', ...}
|
||||||
|
"""
|
||||||
|
# Извлекаем логин из email (user_id@tcs.sibcem.ru -> user_id)
|
||||||
|
login = user_id.split('@')[0]
|
||||||
|
|
||||||
|
status_url = API_URL.replace("/upload/", "/status/")
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
status_url,
|
||||||
|
params={"user_id": login},
|
||||||
|
headers={"X-API-Key": API_KEY},
|
||||||
|
timeout=30,
|
||||||
|
verify=False
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка проверки статуса модерации: {e}")
|
||||||
|
return {"status": "error", "reason": f"Ошибка проверки статуса: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_to_api(image_bytes: bytes, user_id: str, coords: tuple) -> dict:
|
||||||
|
"""
|
||||||
|
Отправляет фото и координаты в Django API.
|
||||||
|
Возвращает JSON от сервера.
|
||||||
|
"""
|
||||||
|
x1, y1, x2, y2 = coords
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
API_URL,
|
||||||
|
headers={"X-API-Key": API_KEY},
|
||||||
|
data={
|
||||||
|
"user_id": user_id,
|
||||||
|
"crop_x1": x1,
|
||||||
|
"crop_y1": y1,
|
||||||
|
"crop_x2": x2,
|
||||||
|
"crop_y2": y2,
|
||||||
|
},
|
||||||
|
files={
|
||||||
|
"image": ("photo.jpg", image_bytes, "image/jpeg")
|
||||||
|
},
|
||||||
|
timeout=300,
|
||||||
|
verify=False
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
# Логгируем ответ для отладки
|
||||||
|
logger.info(f"API response status: {response.status_code}, body[:500]: {response.text[:500]}")
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Ошибка API: {e}")
|
||||||
|
return {"status": "error", "reason": f"Ошибка сети: {str(e)}"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка отправки в API: {e}")
|
||||||
|
return {"status": "error", "reason": f"Ошибка: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_ad_photo(input_path: str, user_id: str, output_path: str = None, target_size: int = 96) -> dict:
|
||||||
|
"""
|
||||||
|
Отправляет фото на сервер через API.
|
||||||
|
output_path и target_size больше не используются (оставлены для обратной совместимости).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Проверяем статус модерации перед отправкой
|
||||||
|
status = _check_moderation_status(user_id)
|
||||||
|
if status.get("status") == "ok" and status.get("moderation_status") == "pending":
|
||||||
|
photo_id = status.get("photo_id", "???")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"У вас уже есть фото на модерации (ID: {photo_id}). Дождитесь решения модератора."
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Читаем байты
|
||||||
|
if isinstance(input_path, bytes):
|
||||||
|
image_bytes = input_path
|
||||||
|
else:
|
||||||
|
with open(input_path, 'rb') as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
|
||||||
|
# 2. Ищем лицо и получаем координаты
|
||||||
|
coords = get_crop_coordinates(image_bytes)
|
||||||
|
if not coords:
|
||||||
|
return {"success": False, "error": "Лицо не найдено! Пожалуйста, сделайте фото анфас при хорошем освещении."}
|
||||||
|
|
||||||
|
# 3. Отправляем в API
|
||||||
|
api_result = _upload_to_api(image_bytes, user_id, coords)
|
||||||
|
|
||||||
|
# 4. Возвращаем результат
|
||||||
|
if api_result.get("status") == "ok":
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"photo_id": api_result.get("photo_id"),
|
||||||
|
"verdict": api_result.get("verdict"),
|
||||||
|
"message": api_result.get("message"),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": api_result.get("reason", "Неизвестная ошибка API")
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка в prepare_ad_photo: {e}", exc_info=True)
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def make_face_crop_bytes(image_bytes: bytes, coords: tuple) -> bytes:
|
||||||
|
"""
|
||||||
|
Вырезает квадратный кадр по центру лица (тот же регион, что превью
|
||||||
|
бота и публикуемый на сайте кроп) и возвращает JPEG-байты.
|
||||||
|
|
||||||
|
На проверку уходит именно этот кроп: правило «голова ≥ 30%» и все
|
||||||
|
дальнейшие проверки имеют смысл для публикуемого результата, а не
|
||||||
|
для произвольно снятого оригинала.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JPEG-байты квадрата (полное разрешение, без ресайза).
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(image_bytes))
|
||||||
|
if img.mode != 'RGB':
|
||||||
|
img = img.convert('RGB')
|
||||||
|
|
||||||
|
x1, y1, x2, y2 = coords
|
||||||
|
cropped = img.crop((x1, y1, x2, y2))
|
||||||
|
|
||||||
|
# Квадрат из центра (как превью)
|
||||||
|
side = min(cropped.size[0], cropped.size[1])
|
||||||
|
left = (cropped.size[0] - side) // 2
|
||||||
|
top = (cropped.size[1] - side) // 2
|
||||||
|
cropped = cropped.crop((left, top, left + side, top + side))
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
cropped.save(buf, format="JPEG", quality=90)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def check_photo_api(image_bytes: bytes, timeout: float = 300.0) -> dict:
|
||||||
|
"""
|
||||||
|
Dry-run проверка фото через API сайта (POST /api/photo-bot/check/).
|
||||||
|
|
||||||
|
image_bytes — АВТО-КРОП ЛИЦА от бота (make_face_crop_bytes), тот же
|
||||||
|
кадр, что и превью/публикация. Фото проходит полный пайплайн проверки
|
||||||
|
на сайте (MediaPipe CV + ARKit-формы + VLM ИИ), НО ни в БД, ни в AD,
|
||||||
|
ни в Orion не попадает — ответ возвращается боту для тест-под-режима.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"status": "ok", "verdict", "ai_reason", "cv_approved", "cv_reason",
|
||||||
|
"raw_answer", "forced_moderation", "timings", "ai_metrics"}
|
||||||
|
или {"status": "error", "reason"}
|
||||||
|
"""
|
||||||
|
url = API_URL.replace("/upload/", "/check/")
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers={"X-API-Key": API_KEY},
|
||||||
|
files={"image": ("photo.jpg", image_bytes, "image/jpeg")},
|
||||||
|
timeout=timeout,
|
||||||
|
verify=False
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logger.info(f"API /check/ response: status={response.status_code}, body[:500]: {response.text[:500]}")
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Ошибка /check/ API: {e}")
|
||||||
|
return {"status": "error", "reason": f"Ошибка сети при обращении к сайту: {e}"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка /check/: {e}")
|
||||||
|
return {"status": "error", "reason": f"Ошибка сайта проверки: {e}"}
|
||||||
|
|||||||
Submodule
+1
Submodule sd_dispatcher added at b7b099cb38
Binary file not shown.
+166
-219
@@ -44,9 +44,9 @@ from utils.texts import (
|
|||||||
sd_uploading_file,
|
sd_uploading_file,
|
||||||
sd_file_attached,
|
sd_file_attached,
|
||||||
sd_file_upload_error,
|
sd_file_upload_error,
|
||||||
|
system_error_text,
|
||||||
|
UNKNOWN_MAIN_CMD_TEXT,
|
||||||
)
|
)
|
||||||
from utils.texts import UNKNOWN_MAIN_CMD_TEXT
|
|
||||||
# 🔌 Импортируем централизованную функцию сбора статистики из main
|
|
||||||
from utils.stats_logger import log_menu_stats
|
from utils.stats_logger import log_menu_stats
|
||||||
|
|
||||||
# Таймаут ожидания вложений после текста (секунды)
|
# Таймаут ожидания вложений после текста (секунды)
|
||||||
@@ -67,61 +67,15 @@ logger = logging.getLogger(__name__)
|
|||||||
router = Router()
|
router = Router()
|
||||||
sd_sessions = {}
|
sd_sessions = {}
|
||||||
|
|
||||||
async def get_sd_templates():
|
|
||||||
url = f"{SD_URL}/api/v3/request_templates"
|
|
||||||
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json", "Content-Type": "application/json"}
|
|
||||||
input_data = {"list_info": {"row_count": 100, "start_index": 1}}
|
|
||||||
params = {"input_data": json.dumps(input_data)}
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(verify=False) as client:
|
|
||||||
response = await client.get(url, headers=headers, params=params, timeout=30.0)
|
|
||||||
if response.status_code != 200:
|
|
||||||
sd_workflow_logger.error(f"Ошибка API шаблонов: HTTP {response.status_code} - {response.text}")
|
|
||||||
return []
|
|
||||||
data = response.json()
|
|
||||||
all_templates = data.get("request_templates", [])
|
|
||||||
pre_filtered = [
|
|
||||||
t for t in all_templates
|
|
||||||
if t.get("status") == "ACTIVE"
|
|
||||||
and not t.get("inactive", False)
|
|
||||||
and str(t.get("id")) != "2"
|
|
||||||
]
|
|
||||||
sd_workflow_logger.info(f"Шаблонов в системе: {len(all_templates)} | Активных: {len(pre_filtered)}")
|
|
||||||
async def check_template(t):
|
|
||||||
try:
|
|
||||||
t_resp = await client.get(url + "/" + str(t["id"]), headers=headers, timeout=15.0)
|
|
||||||
if t_resp.status_code == 200:
|
|
||||||
detailed = t_resp.json().get("request_template", {})
|
|
||||||
if detailed.get("show_to_requester") is True:
|
|
||||||
sc = detailed.get("service_category", {})
|
|
||||||
t["service_category"] = sc
|
|
||||||
t["request"] = detailed.get("request", {})
|
|
||||||
return t if detailed.get("show_to_requester") is True else None
|
|
||||||
except Exception as e:
|
|
||||||
sd_workflow_logger.warning(f"Ошибка деталей шаблона ID {t['id']}: {e}")
|
|
||||||
return None
|
|
||||||
tasks = [check_template(t) for t in pre_filtered]
|
|
||||||
results = await asyncio.gather(*tasks)
|
|
||||||
active_templates = [t for t in results if t is not None]
|
|
||||||
sd_workflow_logger.info(f"Шаблоны с show_to_requester=True: {len(active_templates)}")
|
|
||||||
for temp in active_templates:
|
|
||||||
sd_workflow_logger.info(f" ID: {temp['id']} | Name: {temp['name']}")
|
|
||||||
return active_templates
|
|
||||||
except Exception as e:
|
|
||||||
sd_workflow_logger.error(f"Ошибка загрузки шаблонов: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================
|
# ================================================
|
||||||
# БИЗНЕС-ЛОГИКА
|
# БИЗНЕС-ЛОГИКА
|
||||||
# ================================================
|
# ================================================
|
||||||
def get_ad_user_sync(login: str):
|
def get_ad_user_sync(login: str):
|
||||||
try:
|
try:
|
||||||
server = Server(AD_SERVER, get_info=ALL)
|
from utils.ad_search import search_by_login
|
||||||
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
|
entries = search_by_login(login, ["displayName", "mail", "l", "userAccountControl"])
|
||||||
conn.search(search_base=AD_BASE, search_filter=f"(sAMAccountName={login})", attributes=["displayName", "mail", "l", "userAccountControl"])
|
if entries:
|
||||||
if conn.entries:
|
user = entries[0]
|
||||||
user = conn.entries[0]
|
|
||||||
uac = user.userAccountControl.value if 'userAccountControl' in user else 0
|
uac = user.userAccountControl.value if 'userAccountControl' in user else 0
|
||||||
return {
|
return {
|
||||||
"name": user.displayName.value if 'displayName' in user else login,
|
"name": user.displayName.value if 'displayName' in user else login,
|
||||||
@@ -173,39 +127,31 @@ async def generate_smart_subject(text: str) -> str:
|
|||||||
sd_workflow_logger.error(f"📝 [Subject Gen Error] Exception: {e}\n{traceback.format_exc()}")
|
sd_workflow_logger.error(f"📝 [Subject Gen Error] Exception: {e}\n{traceback.format_exc()}")
|
||||||
return "Заявка из КЛЕВЕР"
|
return "Заявка из КЛЕВЕР"
|
||||||
|
|
||||||
async def create_ticket_in_sd(requester_email: str, subject: str, description: str, city: str, template_id: str | None = None, templates: list | None = None):
|
async def create_ticket_in_sd(requester_email: str, subject: str, description: str, city: str):
|
||||||
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json", "Content-Type": "application/json"}
|
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"}
|
||||||
endpoint = f"{SD_URL}/api/v3/requests"
|
endpoint = f"{SD_URL}/api/v3/requests"
|
||||||
html_desc = f"<p>{description.replace(chr(10), '<br>')}</p><br><hr><p style='color:#555;font-size:12px;'><i>Создано через TrueConf КЛЕВЕР</i></p>"
|
html_desc = f"<p>{description.replace(chr(10), '<br>')}</p><br><hr><p style='color:#555;font-size:12px;'><i>Создано через TrueConf КЛЕВЕР</i></p>"
|
||||||
async def _send_req(email, current_city, template_id, templates):
|
|
||||||
req_payload = {"request": {"subject": subject, "description": html_desc, "requester": {"name": email}}}
|
async def _send_req(email, current_city):
|
||||||
if template_id:
|
payload = {"request": {"subject": subject, "description": html_desc, "requester": {"email_id": email}, "udf_fields": {"udf_pick_301": current_city}}}
|
||||||
req_payload["request"]["template"] = {"id": template_id}
|
|
||||||
# Найти service_category по шаблону
|
|
||||||
for t in templates:
|
|
||||||
if str(t.get("id")) == str(template_id):
|
|
||||||
sc = t.get("service_category", {})
|
|
||||||
if sc:
|
|
||||||
req_payload["request"]["service_category"] = sc
|
|
||||||
break
|
|
||||||
if "udf_pick_301" in req_payload["request"]:
|
|
||||||
req_payload["request"]["udf_fields"] = {"udf_pick_301": current_city}
|
|
||||||
elif "udf_fields" in req_payload["request"] and current_city:
|
|
||||||
req_payload["request"]["udf_fields"]["udf_pick_301"] = current_city
|
|
||||||
else:
|
|
||||||
req_payload["request"]["udf_fields"] = {"udf_pick_301": current_city}
|
|
||||||
async with httpx.AsyncClient(verify=False) as client:
|
async with httpx.AsyncClient(verify=False) as client:
|
||||||
resp = await client.post(endpoint, headers=headers, params={"input_data": json.dumps(req_payload)}, timeout=15.0)
|
return await client.post(endpoint, headers=headers, data={"input_data": json.dumps(payload)}, timeout=15.0)
|
||||||
if resp.status_code not in [200, 201]:
|
|
||||||
sd_workflow_logger.error(f"API Error: status={resp.status_code} body={resp.text}")
|
# Безопасная обработка сбоев сети / отсуствия доступа к серверу SD
|
||||||
return resp
|
try:
|
||||||
resp = await _send_req(requester_email, city, template_id, templates)
|
resp = await _send_req(requester_email, city)
|
||||||
data = resp.json() if resp.status_code in [200, 201] else {}
|
|
||||||
if data.get("response_status", {}).get("status_code") != 2000:
|
|
||||||
resp = await _send_req(requester_email, "Кемерово", template_id, templates)
|
|
||||||
data = resp.json() if resp.status_code in [200, 201] else {}
|
data = resp.json() if resp.status_code in [200, 201] else {}
|
||||||
if data.get("response_status", {}).get("status_code") == 2000:
|
if data.get("response_status", {}).get("status_code") != 2000:
|
||||||
return data.get("request", {}).get("id")
|
resp = await _send_req(requester_email, "Кемерово")
|
||||||
|
data = resp.json() if resp.status_code in [200, 201] else {}
|
||||||
|
if data.get("response_status", {}).get("status_code") == 2000:
|
||||||
|
return data.get("request", {}).get("id")
|
||||||
|
except (httpx.RequestError, httpx.HTTPStatusError, Exception) as e:
|
||||||
|
logger.error(f"❌ [SD Network Error] Не удалось подключиться к ServiceDesk: {e}")
|
||||||
|
sd_workflow_logger.error(f"❌ [SD Network Error] {e}")
|
||||||
|
_sd_log_and_notify_email("Подключение к ServiceDesk", str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def process_and_upload_file(file_id: str, filename: str, ticket_id: str):
|
async def process_and_upload_file(file_id: str, filename: str, ticket_id: str):
|
||||||
@@ -283,8 +229,7 @@ def _extract_attachments_from_content(content, is_attachment_type=False):
|
|||||||
async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login):
|
async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login):
|
||||||
"""Логика генерации темы, отправки в SD и загрузки всех очередей вложений."""
|
"""Логика генерации темы, отправки в SD и загрузки всех очередей вложений."""
|
||||||
session["step"] = "creating_ticket"
|
session["step"] = "creating_ticket"
|
||||||
template_id = session.get("template_id")
|
|
||||||
sd_workflow_logger.info(f"[Create] Template ID: {template_id}")
|
|
||||||
try:
|
try:
|
||||||
ad_user = await asyncio.to_thread(get_ad_user_sync, login)
|
ad_user = await asyncio.to_thread(get_ad_user_sync, login)
|
||||||
sd_workflow_logger.info(f"👤 [AD Lookup] User: {login} -> Found: {ad_user is not None}")
|
sd_workflow_logger.info(f"👤 [AD Lookup] User: {login} -> Found: {ad_user is not None}")
|
||||||
@@ -293,13 +238,13 @@ async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login
|
|||||||
subject = await generate_smart_subject(msg_text)
|
subject = await generate_smart_subject(msg_text)
|
||||||
sd_workflow_logger.info(f"📝 [Subject Gen] Text: {msg_text[:50]}... -> Subject: {subject}")
|
sd_workflow_logger.info(f"📝 [Subject Gen] Text: {msg_text[:50]}... -> Subject: {subject}")
|
||||||
|
|
||||||
ticket_id = await create_ticket_in_sd(sender_email, subject, msg_text, city, template_id, session.get("templates", []))
|
ticket_id = await create_ticket_in_sd(sender_email, subject, msg_text, city)
|
||||||
sd_workflow_logger.info(f"🎫 [Ticket Created] ID: {ticket_id}")
|
sd_workflow_logger.info(f"🎫 [Ticket Created] ID: {ticket_id}")
|
||||||
|
|
||||||
if ticket_id:
|
if ticket_id:
|
||||||
session["ticket_id"] = ticket_id
|
session["ticket_id"] = ticket_id
|
||||||
|
|
||||||
# 1. Загрузка основных вложений (накопленных до и во время таймаута)
|
# 1. Загрузка основных вложений
|
||||||
queued_files = list(session.get("files_queue", []))
|
queued_files = list(session.get("files_queue", []))
|
||||||
if queued_files:
|
if queued_files:
|
||||||
sd_workflow_logger.info(f"📎 [Upload] Uploading {len(queued_files)} file(s) to ticket #{ticket_id}")
|
sd_workflow_logger.info(f"📎 [Upload] Uploading {len(queued_files)} file(s) to ticket #{ticket_id}")
|
||||||
@@ -307,7 +252,7 @@ async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login
|
|||||||
await process_and_upload_file(f['file_id'], f['file_name'], ticket_id)
|
await process_and_upload_file(f['file_id'], f['file_name'], ticket_id)
|
||||||
sd_workflow_logger.info(f"✅ [Upload] All queued file(s) uploaded")
|
sd_workflow_logger.info(f"✅ [Upload] All queued file(s) uploaded")
|
||||||
|
|
||||||
# 2. Загрузка вложений, прилетевших во время выполнения API-запросов (из "черной дыры")
|
# 2. Загрузка вложений, прилетевших во время выполнения API-запросов
|
||||||
post_files = list(session.get("post_create_queue", []))
|
post_files = list(session.get("post_create_queue", []))
|
||||||
if post_files:
|
if post_files:
|
||||||
sd_workflow_logger.info(f"📎 [Upload Extra] Uploading {len(post_files)} late file(s) to ticket #{ticket_id}")
|
sd_workflow_logger.info(f"📎 [Upload Extra] Uploading {len(post_files)} late file(s) to ticket #{ticket_id}")
|
||||||
@@ -325,19 +270,20 @@ async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login
|
|||||||
log_menu_stats(user_id, "Service Desk", f"Создание заявки #{ticket_id}")
|
log_menu_stats(user_id, "Service Desk", f"Создание заявки #{ticket_id}")
|
||||||
else:
|
else:
|
||||||
session["step"] = "need_text"
|
session["step"] = "need_text"
|
||||||
await msg.answer(sd_ticket_create_error(), parse_mode="html")
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Ошибка: {e}")
|
logger.exception(f"❌ [SD Ticket Error] {user_id}: {e}")
|
||||||
sd_workflow_logger.error(f"❌ [SD Error] {e}")
|
sd_workflow_logger.error(f"❌ [SD Ticket Error] {user_id}: {e}")
|
||||||
|
_sd_log_and_notify_email(f"Создание заявки для {user_id}", str(e))
|
||||||
session["step"] = "need_text"
|
session["step"] = "need_text"
|
||||||
await msg.answer(sd_system_error(), parse_mode="html")
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
|
|
||||||
async def _wait_for_attachments_and_create(user_id, session):
|
async def _wait_for_attachments_and_create(user_id, session):
|
||||||
"""Фоновый таймер с возможностью отмены (Debounce)."""
|
"""Фоновый таймер с возможностью отмены (Debounce)."""
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(ATTACHMENT_WAIT_TIMEOUT)
|
await asyncio.sleep(ATTACHMENT_WAIT_TIMEOUT)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return # Таймер сброшен, прерываем выполнение текущей таски
|
return
|
||||||
|
|
||||||
if session.get("step") != "waiting_for_attachments":
|
if session.get("step") != "waiting_for_attachments":
|
||||||
return
|
return
|
||||||
@@ -346,144 +292,145 @@ async def _wait_for_attachments_and_create(user_id, session):
|
|||||||
msg = session.get("msg")
|
msg = session.get("msg")
|
||||||
login = session.get("login")
|
login = session.get("login")
|
||||||
if msg:
|
if msg:
|
||||||
await _create_ticket_and_attach_files(user_id, session["msg_text"], session, msg, login)
|
try:
|
||||||
|
await _create_ticket_and_attach_files(user_id, session["msg_text"], session, msg, login)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"❌ [SD Ticket Failed] user={user_id}: {e}")
|
||||||
|
sd_workflow_logger.error(f"❌ [SD Ticket] {e}")
|
||||||
|
_sd_log_and_notify_email(f"Создание заявки для {user_id}", str(e))
|
||||||
|
session["step"] = "need_text"
|
||||||
|
try:
|
||||||
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
|
except Exception:
|
||||||
|
sd_workflow_logger.error(f"❌ [SD] Failed to send error message to {user_id}")
|
||||||
|
|
||||||
|
def _on_sd_timer_done(task: asyncio.Task, user_id: str):
|
||||||
|
"""Безопасный коллбэк завершения таймера создания заявки"""
|
||||||
|
try:
|
||||||
|
task.result()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка в таске таймера SD для {user_id}: {e}")
|
||||||
|
sd_workflow_logger.error(f"❌ [SD Timer Error] {user_id}: {e}")
|
||||||
|
_sd_log_and_notify_email(f"Таймер создания заявки для {user_id}", str(e))
|
||||||
|
|
||||||
@router.message()
|
@router.message()
|
||||||
async def sd_module_handler(msg: Message):
|
async def sd_module_handler(msg: Message):
|
||||||
user_id = msg.from_user.id
|
try:
|
||||||
sd_workflow_logger.info(f"🚀 [SD Workflow Start] User: {user_id}")
|
user_id = msg.from_user.id
|
||||||
if states.get_state(user_id) != "SD_MODE":
|
sd_workflow_logger.info(f"🚀 [SD Workflow Start] User: {user_id}")
|
||||||
return
|
if states.get_state(user_id) != "SD_MODE":
|
||||||
|
|
||||||
msg_text = _extract_text_from_content(msg.content)
|
|
||||||
is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT"
|
|
||||||
inline_attachments = _extract_attachments_from_content(msg.content, is_attachment)
|
|
||||||
|
|
||||||
sd_workflow_logger.info(f"🚀 [SD Debug] msg.type={msg.type}, content_type={type(msg.content).__name__}, msg_text='{msg_text}', is_attachment={is_attachment}, inline_attachments={len(inline_attachments)}")
|
|
||||||
|
|
||||||
msg.handled = True
|
|
||||||
login = user_id.split("@")[0] if "@" in user_id else user_id
|
|
||||||
cmd = msg_text.lower()
|
|
||||||
|
|
||||||
# 🔄 НОРМАЛИЗАЦИЯ КНОПОК ВК-ЭМОДЗИ
|
|
||||||
for raw_num, emoji_num in EMOJI_DIGITS.items():
|
|
||||||
if cmd == emoji_num:
|
|
||||||
cmd = raw_num
|
|
||||||
break
|
|
||||||
|
|
||||||
# --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню) ---
|
|
||||||
if cmd in ["0", "9", "/start", "меню"]:
|
|
||||||
log_menu_stats(user_id, "Service Desk", "Выход в главное меню")
|
|
||||||
states.clear_state(user_id)
|
|
||||||
sd_sessions.pop(user_id, None)
|
|
||||||
await msg.answer(MENU_TEXT, parse_mode="html")
|
|
||||||
return
|
|
||||||
|
|
||||||
if user_id not in sd_sessions:
|
|
||||||
sd_sessions[user_id] = {"step": "need_text", "files_queue": [], "post_create_queue": [], "templates": [], "template_names": {}}
|
|
||||||
session = sd_sessions[user_id]
|
|
||||||
|
|
||||||
|
|
||||||
# --- ШАГ 0: ВЫБОР ШАБЛОНА ---
|
|
||||||
if session["step"] == "choose_template":
|
|
||||||
if cmd in ["skip", "пропустить", "без шаблона"]:
|
|
||||||
sd_workflow_logger.info("[Template] User skipped template selection")
|
|
||||||
session["step"] = "need_text"
|
|
||||||
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
|
|
||||||
return
|
|
||||||
if cmd in session.get("template_names", {}):
|
|
||||||
template_id = session["template_names"][cmd]
|
|
||||||
template_name = next((t["name"] for t in session.get("templates", []) if str(t["id"]) == str(template_id)), "Неизвестно")
|
|
||||||
sd_workflow_logger.info(f"[Template] User selected: {template_id} ({template_name})")
|
|
||||||
session["template_id"] = template_id
|
|
||||||
session["step"] = "need_text"
|
|
||||||
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
await msg.answer(
|
|
||||||
f"Неверный номер. Выберите из списка:\n\n"
|
|
||||||
f"{session.get('template_menu_text', '')}",
|
|
||||||
parse_mode="html"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- ШАГ 1: ОЖИДАНИЕ ТЕКСТА ---
|
msg_text = _extract_text_from_content(msg.content)
|
||||||
if session["step"] == "need_text":
|
is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT"
|
||||||
# Пользователь только что вошёл в SD_MODE — показываем меню
|
inline_attachments = _extract_attachments_from_content(msg.content, is_attachment)
|
||||||
if len(msg_text.strip()) == 0 and not inline_attachments:
|
|
||||||
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
|
sd_workflow_logger.info(f"🚀 [SD Debug] msg.type={msg.type}, content_type={type(msg.content).__name__}, msg_text='{msg_text}', is_attachment={is_attachment}, inline_attachments={len(inline_attachments)}")
|
||||||
return
|
|
||||||
if not msg_text.strip() and not inline_attachments:
|
msg.handled = True
|
||||||
await msg.answer(SD_UNKNOWN_CMD_TEXT, parse_mode="html")
|
login = user_id.split("@")[0] if "@" in user_id else user_id
|
||||||
|
cmd = msg_text.lower()
|
||||||
|
|
||||||
|
# 🔄 НОРМАЛИЗАЦИЯ КНОПОК ВК-ЭМОДЗИ
|
||||||
|
for raw_num, emoji_num in EMOJI_DIGITS.items():
|
||||||
|
if cmd == emoji_num:
|
||||||
|
cmd = raw_num
|
||||||
|
break
|
||||||
|
|
||||||
|
# --- ВЫХОД ---
|
||||||
|
if cmd in ["0", "9", "/start", "меню"]:
|
||||||
|
log_menu_stats(user_id, "Service Desk", "Выход в главное меню")
|
||||||
|
states.clear_state(user_id)
|
||||||
|
sd_sessions.pop(user_id, None)
|
||||||
|
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Если только вложение без текста — сохраняем и ждем текст
|
if user_id not in sd_sessions:
|
||||||
if not msg_text.strip() and inline_attachments:
|
sd_sessions[user_id] = {"step": "need_text", "files_queue": [], "post_create_queue": []}
|
||||||
session["files_queue"].extend(inline_attachments)
|
session = sd_sessions[user_id]
|
||||||
sd_workflow_logger.info(f"📎 [SD] Saved {len(inline_attachments)} attachment(s), waiting for text")
|
|
||||||
await msg.answer(SD_TEXT_REQUIRED, parse_mode="html")
|
# --- ШАГ 1: ОЖИДАНИЕ ТЕКСТА ---
|
||||||
|
if session["step"] == "need_text":
|
||||||
|
if not msg_text.strip() and not inline_attachments:
|
||||||
|
await msg.answer(SD_UNKNOWN_CMD_TEXT, parse_mode="html")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not msg_text.strip() and inline_attachments:
|
||||||
|
session["files_queue"].extend(inline_attachments)
|
||||||
|
sd_workflow_logger.info(f"📎 [SD] Saved {len(inline_attachments)} attachment(s), waiting for text")
|
||||||
|
await msg.answer(SD_TEXT_REQUIRED, parse_mode="html")
|
||||||
|
return
|
||||||
|
|
||||||
|
if inline_attachments:
|
||||||
|
session["files_queue"].extend(inline_attachments)
|
||||||
|
sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue")
|
||||||
|
|
||||||
|
session["step"] = "waiting_for_attachments"
|
||||||
|
session["msg_text"] = msg_text
|
||||||
|
session["msg"] = msg
|
||||||
|
session["user_id"] = user_id
|
||||||
|
session["login"] = login
|
||||||
|
|
||||||
|
await msg.answer(SD_CREATING, parse_mode="html")
|
||||||
|
|
||||||
|
if "timer_task" in session and not session["timer_task"].done():
|
||||||
|
session["timer_task"].cancel()
|
||||||
|
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
|
||||||
|
session["timer_task"].add_done_callback(lambda t: _on_sd_timer_done(t, user_id))
|
||||||
|
sd_workflow_logger.info(f"⏳ [SD] Timer started: waiting {ATTACHMENT_WAIT_TIMEOUT}s for potentially more attachments...")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Если пришел текст (с вложениями или без)
|
# --- ШАГ 2: РЕЖИМ ОЖИДАНИЯ ДОП. ВЛОЖЕНИЙ ---
|
||||||
if inline_attachments:
|
if session["step"] == "waiting_for_attachments":
|
||||||
session["files_queue"].extend(inline_attachments)
|
if inline_attachments:
|
||||||
sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue")
|
session["files_queue"].extend(inline_attachments)
|
||||||
|
sd_workflow_logger.info(f"📎 [SD] Received {len(inline_attachments)} more files. Total queue: {len(session['files_queue'])}")
|
||||||
|
if msg_text.strip():
|
||||||
|
session["msg_text"] = session["msg_text"] + " " + msg_text.strip()
|
||||||
|
sd_workflow_logger.info(f"📝 [SD] Additional text appended")
|
||||||
|
|
||||||
# Настраиваем параметры сессии для ожидания
|
if "timer_task" in session and not session["timer_task"].done():
|
||||||
session["step"] = "waiting_for_attachments"
|
session["timer_task"].cancel()
|
||||||
session["msg_text"] = msg_text
|
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
|
||||||
session["msg"] = msg
|
session["timer_task"].add_done_callback(lambda t: _on_sd_timer_done(t, user_id))
|
||||||
session["user_id"] = user_id
|
sd_workflow_logger.info(f"🔄 [SD] Timer reset due to user activity. Waiting another {ATTACHMENT_WAIT_TIMEOUT}s")
|
||||||
session["login"] = login
|
return
|
||||||
|
|
||||||
# Сразу выдаем ОДНО сообщение пользователю, чтобы он видел реакцию бота
|
# --- ШАГ 3: ЗАЯВКА СОЗДАЕТСЯ ---
|
||||||
await msg.answer(SD_CREATING, parse_mode="html")
|
if session["step"] == "creating_ticket":
|
||||||
|
if inline_attachments:
|
||||||
|
if "post_create_queue" not in session:
|
||||||
|
session["post_create_queue"] = []
|
||||||
|
session["post_create_queue"].extend(inline_attachments)
|
||||||
|
sd_workflow_logger.info(f"📥 [SD Hole-Fix] Captured {len(inline_attachments)} file(s) DURING ticket creation API call.")
|
||||||
|
return
|
||||||
|
|
||||||
# Запускаем фоновый таймер с механизмом сброса (Debounce)
|
# --- ШАГ 4: ПРИЕМ ДОП. ФАЙЛОВ К УЖЕ СОЗДАННОЙ ЗАЯВКЕ ---
|
||||||
if "timer_task" in session and not session["timer_task"].done():
|
if session["step"] == "ticket_created":
|
||||||
session["timer_task"].cancel()
|
if inline_attachments:
|
||||||
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
|
ticket_id = session["ticket_id"]
|
||||||
sd_workflow_logger.info(f"⏳ [SD] Timer started: waiting {ATTACHMENT_WAIT_TIMEOUT}s for potentially more attachments...")
|
for att in inline_attachments:
|
||||||
return
|
await msg.answer(sd_uploading_file(ticket_id), parse_mode="html")
|
||||||
|
ok = await process_and_upload_file(att['file_id'], att['file_name'], ticket_id)
|
||||||
|
sd_workflow_logger.info(f"📎 [Late File Upload] Name: {att['file_name']} -> Success: {ok}")
|
||||||
|
if ok:
|
||||||
|
log_menu_stats(user_id, "Service Desk", f"Добавление файла к заявке #{ticket_id}")
|
||||||
|
await msg.answer(sd_file_attached(), parse_mode="html")
|
||||||
|
else:
|
||||||
|
_sd_log_and_notify_email(f"Загрузка файла к заявке #{ticket_id}", f"file={att.get('file_name', 'unknown')}")
|
||||||
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
|
elif msg_text.strip():
|
||||||
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
user_id = getattr(msg.from_user, 'id', 'unknown')
|
||||||
|
logger.exception(f"❌ [SD Fatal Error] user={user_id}: {e}")
|
||||||
|
sd_workflow_logger.error(f"❌ [SD Fatal] {e}")
|
||||||
|
_sd_log_and_notify_email(f"Обработка сообщения от {user_id}", str(e))
|
||||||
|
try:
|
||||||
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# --- ШАГ 2: РЕЖИМ ОЖИДАНИЯ ДОП. ВЛОЖЕНИЙ (Сброс таймаута) ---
|
|
||||||
if session["step"] == "waiting_for_attachments":
|
|
||||||
if inline_attachments:
|
|
||||||
session["files_queue"].extend(inline_attachments)
|
|
||||||
sd_workflow_logger.info(f"📎 [SD] Received {len(inline_attachments)} more files. Total queue: {len(session['files_queue'])}")
|
|
||||||
if msg_text.strip():
|
|
||||||
session["msg_text"] = session["msg_text"] + " " + msg_text.strip()
|
|
||||||
sd_workflow_logger.info(f"📝 [SD] Additional text appended")
|
|
||||||
|
|
||||||
# Перезапускаем таймер (пользователь активен, сдвигаем окно создания вперед)
|
|
||||||
if "timer_task" in session and not session["timer_task"].done():
|
|
||||||
session["timer_task"].cancel()
|
|
||||||
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
|
|
||||||
sd_workflow_logger.info(f"🔄 [SD] Timer reset due to user activity. Waiting another {ATTACHMENT_WAIT_TIMEOUT}s")
|
|
||||||
return
|
|
||||||
|
|
||||||
# --- ШАГ 3: ЗАЯВКА СОЗДАЕТСЯ (Перехват файлов из "черной дыры") ---
|
|
||||||
if session["step"] == "creating_ticket":
|
|
||||||
if inline_attachments:
|
|
||||||
if "post_create_queue" not in session:
|
|
||||||
session["post_create_queue"] = []
|
|
||||||
session["post_create_queue"].extend(inline_attachments)
|
|
||||||
sd_workflow_logger.info(f"📥 [SD Hole-Fix] Captured {len(inline_attachments)} file(s) DURING ticket creation API call.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# --- ШАГ 4: ПРИЕМ ДОП. ФАЙЛОВ К УЖЕ СОЗДАННОЙ ЗАЯВКЕ ---
|
|
||||||
if session["step"] == "ticket_created":
|
|
||||||
if inline_attachments:
|
|
||||||
ticket_id = session["ticket_id"]
|
|
||||||
for att in inline_attachments:
|
|
||||||
await msg.answer(sd_uploading_file(ticket_id), parse_mode="html")
|
|
||||||
ok = await process_and_upload_file(att['file_id'], att['file_name'], ticket_id)
|
|
||||||
sd_workflow_logger.info(f"📎 [Late File Upload] Name: {att['file_name']} -> Success: {ok}")
|
|
||||||
if ok:
|
|
||||||
log_menu_stats(user_id, "Service Desk", f"Добавление файла к заявке #{ticket_id}")
|
|
||||||
await msg.answer(sd_file_attached(), parse_mode="html")
|
|
||||||
else:
|
|
||||||
await msg.answer(sd_file_upload_error(), parse_mode="html")
|
|
||||||
elif msg_text.strip():
|
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
|
||||||
return
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Проверка подключения к SQL Server и загрузки данных табеля
|
||||||
|
за последние 5 месяцев для ds.krivochenko@sibcem.ru.
|
||||||
|
|
||||||
|
Цепочка поиска сотрудника (как в LK модуле бота):
|
||||||
|
1. AD: search_by_user_id(email) → extensionAttribute2 = CARD_ID
|
||||||
|
2. SQL: SELECT ID FROM UOV_SELFSERVICE_PR_EMP WHERE CARD_ID = %s → EMP_ID
|
||||||
|
3. SQL: SELECT ... FROM UOV_SELFSERVICE_TB_TABEL WHERE EMP_ID = %s → табель
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
cd /opt/trueconf_bot
|
||||||
|
python3 test_tabel_check.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import calendar
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 1. Загрузка .env
|
||||||
|
# ============================================================
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ENV_PATH = os.path.join(BASE_DIR, "config", ".env")
|
||||||
|
|
||||||
|
def load_env(filepath):
|
||||||
|
try:
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#"):
|
||||||
|
if "=" in line:
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
os.environ[key.strip()] = value.strip(' "\'\r\n')
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"⚠️ Файл {filepath} не найден.")
|
||||||
|
|
||||||
|
load_env(ENV_PATH)
|
||||||
|
|
||||||
|
# Карта соответствия: Имя переменной в боте -> Название карточки в сейфе
|
||||||
|
CREDENTIALS_MAP = {
|
||||||
|
"DB_PASSWORD": os.getenv("PW_ID_SQL", "").strip(' "\'\r\n'),
|
||||||
|
"AD_PASSWORD": os.getenv("PW_ID_AD", "").strip(' "\'\r\n'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 2. Загрузка паролей из Passwork
|
||||||
|
# ============================================================
|
||||||
|
DB_USER = ""
|
||||||
|
DB_PASSWORD = ""
|
||||||
|
AD_USER = ""
|
||||||
|
AD_PASSWORD = ""
|
||||||
|
|
||||||
|
for pw_path in ["/opt/passwork", os.path.join(BASE_DIR, "passwork"), os.path.join(BASE_DIR, "config", "passwork")]:
|
||||||
|
if os.path.isdir(pw_path) and pw_path not in sys.path:
|
||||||
|
sys.path.insert(0, pw_path)
|
||||||
|
if BASE_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, BASE_DIR)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from passwork import get_passwork_secrets
|
||||||
|
print("🔐 Загрузка секретов из Passwork...")
|
||||||
|
required_cards = [name for name in CREDENTIALS_MAP.values() if name]
|
||||||
|
passwork_pool = get_passwork_secrets(required_cards=required_cards)
|
||||||
|
for var_name, card_name in CREDENTIALS_MAP.items():
|
||||||
|
if not card_name:
|
||||||
|
continue
|
||||||
|
clean_card = card_name.strip(' "\'\r\n')
|
||||||
|
card_data = passwork_pool.get(clean_card)
|
||||||
|
if card_data is None:
|
||||||
|
print(f" ⚠️ Карточка '{clean_card}' не найдена")
|
||||||
|
continue
|
||||||
|
globals()[var_name] = card_data["password"].strip(' "\'\r\n')
|
||||||
|
if var_name == "AD_PASSWORD":
|
||||||
|
globals()["AD_USER"] = card_data.get("login", "").strip(' "\'\r\n')
|
||||||
|
elif var_name == "DB_PASSWORD":
|
||||||
|
globals()["DB_USER"] = card_data.get("login", "").strip(' "\'\r\n')
|
||||||
|
print(f" ✓ {var_name} из карточки '{clean_card}'")
|
||||||
|
except ImportError:
|
||||||
|
print("⚠️ Модуль passwork не найден")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Ошибка Passwork: {e}")
|
||||||
|
|
||||||
|
if not DB_PASSWORD:
|
||||||
|
DB_PASSWORD = os.getenv("DB_PASSWORD", "").strip()
|
||||||
|
DB_USER = os.getenv("DB_USER", DB_USER).strip()
|
||||||
|
if not AD_PASSWORD:
|
||||||
|
AD_PASSWORD = os.getenv("AD_PASSWORD", "").strip()
|
||||||
|
AD_USER = os.getenv("AD_USER", AD_USER).strip()
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 3. Настройки подключения
|
||||||
|
# ============================================================
|
||||||
|
DB_SERVER = os.getenv("SQL_SERVER", "SRVKEM-MOBILEIN.sibcem.ru")
|
||||||
|
DB_NAME = os.getenv("SQL_DB_NAME", "BossCopy")
|
||||||
|
AD_SERVER = os.getenv("AD_SERVER", "ldap://172.16.20.20")
|
||||||
|
AD_BASES = (
|
||||||
|
"OU=-Пользователи,DC=sibcem,DC=ru",
|
||||||
|
"OU=Планшеты,OU=enabled,OU=БезКомпьютеров,DC=sibcem,DC=ru",
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print(" Проверка подключения к SQL Server")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f" Сервер : {DB_SERVER}")
|
||||||
|
print(f" База : {DB_NAME}")
|
||||||
|
print(f" Логин : {DB_USER}")
|
||||||
|
print(f" Пароль : {'*' * len(DB_PASSWORD) if DB_PASSWORD else '(не задан)'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 4. Проверка pymssql
|
||||||
|
# ============================================================
|
||||||
|
try:
|
||||||
|
import pymssql
|
||||||
|
print(f"✅ pymssql загружен (версия {pymssql.__version__})")
|
||||||
|
except ImportError:
|
||||||
|
print("❌ pymssql не установлен!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 5. Подключение к SQL Server
|
||||||
|
# ============================================================
|
||||||
|
try:
|
||||||
|
conn = pymssql.connect(
|
||||||
|
server=DB_SERVER,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASSWORD,
|
||||||
|
database=DB_NAME,
|
||||||
|
charset='cp1251',
|
||||||
|
login_timeout=10
|
||||||
|
)
|
||||||
|
print("✅ Подключение к SQL Server успешно!")
|
||||||
|
cursor = conn.cursor()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка подключения: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 6. Поиск сотрудника в AD по email → CARD_ID (как в LK модуле)
|
||||||
|
# ============================================================
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print(" Поиск сотрудника в Active Directory")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
TARGET_EMAIL = "ds.krivochenko@sibcem.ru"
|
||||||
|
print(f" Ищем: {TARGET_EMAIL}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ldap3 import Server, Connection, ALL
|
||||||
|
|
||||||
|
server = Server(AD_SERVER, get_info=ALL)
|
||||||
|
ldap_conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
|
||||||
|
|
||||||
|
# Как в ad_search.py: ищем по mail, userPrincipalName, sAMAccountName
|
||||||
|
short_username = TARGET_EMAIL.split("@")[0] if "@" in TARGET_EMAIL else TARGET_EMAIL
|
||||||
|
|
||||||
|
or_parts = [
|
||||||
|
"(mail={})".format(TARGET_EMAIL),
|
||||||
|
"(userPrincipalName={})".format(TARGET_EMAIL),
|
||||||
|
"(userPrincipalName={})".format(short_username + "@sibcem.ru"),
|
||||||
|
"(sAMAccountName={})".format(short_username),
|
||||||
|
]
|
||||||
|
or_filter = "(|{})".format("".join(or_parts))
|
||||||
|
search_filter = "(&{}{})".format("(objectClass=user)", or_filter)
|
||||||
|
|
||||||
|
print(f" LDAP-фильтр: {search_filter}")
|
||||||
|
|
||||||
|
card_id_from_ad = None
|
||||||
|
|
||||||
|
for base in AD_BASES:
|
||||||
|
ldap_conn.search(
|
||||||
|
search_base=base,
|
||||||
|
search_filter=search_filter,
|
||||||
|
attributes=["cn", "sAMAccountName", "mail", "userPrincipalName", "extensionAttribute2"],
|
||||||
|
)
|
||||||
|
if ldap_conn.entries:
|
||||||
|
entry = ldap_conn.entries[0]
|
||||||
|
print(f" ✅ Найден в OU: {base}")
|
||||||
|
print(f" CN : {entry.cn}")
|
||||||
|
print(f" sAMAccountName : {entry.sAMAccountName}")
|
||||||
|
print(f" mail : {entry.mail}")
|
||||||
|
ext2 = str(entry.extensionAttribute2) if hasattr(entry, 'extensionAttribute2') and entry.extensionAttribute2 else "(не задан)"
|
||||||
|
print(f" extensionAttribute2: {ext2}")
|
||||||
|
card_id_from_ad = ext2
|
||||||
|
break
|
||||||
|
|
||||||
|
if not ldap_conn.entries:
|
||||||
|
print(f" ❌ Сотрудник не найден в AD по email {TARGET_EMAIL}")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
print("❌ ldap3 не установлен")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка LDAP: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 7. Поиск EMP_ID по CARD_ID в SQL (как в LK модуле)
|
||||||
|
# ============================================================
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print(" Поиск EMP_ID по CARD_ID в SQL")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
emp_id = None
|
||||||
|
card_id = None
|
||||||
|
|
||||||
|
if card_id_from_ad:
|
||||||
|
card_id = card_id_from_ad
|
||||||
|
print(f" CARD_ID из AD: {card_id}")
|
||||||
|
|
||||||
|
# Как в info_service.py: SELECT e.ID FROM UOV_SELFSERVICE_PR_EMP e WHERE e.CARD_ID = %s
|
||||||
|
cursor.execute("SELECT e.ID FROM UOV_SELFSERVICE_PR_EMP e WHERE e.CARD_ID = %s", (card_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
emp_id = row[0]
|
||||||
|
print(f" ✅ Найден: emp_id={emp_id}")
|
||||||
|
else:
|
||||||
|
print(f" ❌ Сотрудник с CARD_ID={card_id} не найден в UOV_SELFSERVICE_PR_EMP")
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 8. Загрузка табеля за последние 5 месяцев
|
||||||
|
# ============================================================
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print(" Загрузка табеля за последние 5 месяцев")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if not emp_id:
|
||||||
|
print(" ❌ Не удалось найти сотрудника — завершение")
|
||||||
|
conn.close()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
months_back = 5
|
||||||
|
|
||||||
|
for i in range(months_back):
|
||||||
|
month = now.month - i
|
||||||
|
year = now.year
|
||||||
|
while month <= 0:
|
||||||
|
month += 12
|
||||||
|
year -= 1
|
||||||
|
|
||||||
|
month_name = calendar.month_name[month]
|
||||||
|
print(f"\n📅 {month_name} {year}")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT e.D, e.TDAY_ID, e.H
|
||||||
|
FROM UOV_SELFSERVICE_TB_TABEL e
|
||||||
|
WHERE e.EMP_ID = %s AND YEAR(e.D) = %s AND MONTH(e.D) = %s
|
||||||
|
ORDER BY e.D ASC
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cursor.execute(query, (emp_id, year, month))
|
||||||
|
records = cursor.fetchall()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Ошибка запроса: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
print(f" ⚠️ Данные табеля отсутствуют")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" Записей: {len(records)}")
|
||||||
|
|
||||||
|
total_days = 0
|
||||||
|
total_hours = 0.0
|
||||||
|
codes = {}
|
||||||
|
|
||||||
|
for r in records:
|
||||||
|
tday_id = str(r[1]).strip() if r[1] else "?"
|
||||||
|
hours = float(r[2]) if r[2] is not None else 0.0
|
||||||
|
codes[tday_id] = codes.get(tday_id, 0) + 1
|
||||||
|
if tday_id == 'Я':
|
||||||
|
total_days += 1
|
||||||
|
total_hours += hours
|
||||||
|
|
||||||
|
print(f" Явки (Я): {codes.get('Я', 0)} дн., часов: {total_hours:.1f}")
|
||||||
|
print(f" Коды: {', '.join(f'{k}={v}' for k, v in codes.items())}")
|
||||||
|
|
||||||
|
print(f" Первые 5 записей:")
|
||||||
|
for r in records[:5]:
|
||||||
|
d = r[0].strftime('%d.%m.%Y') if hasattr(r[0], 'strftime') else str(r[0])
|
||||||
|
print(f" {d} | {str(r[1]).strip():>3} | {float(r[2]) if r[2] else 0:.1f} ч.")
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Закрытие
|
||||||
|
# ============================================================
|
||||||
|
conn.close()
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print(" ✅ Проверка завершена")
|
||||||
|
print("=" * 60)
|
||||||
Binary file not shown.
@@ -16,14 +16,64 @@ from utils.texts import (
|
|||||||
transcription_error_bad_extension,
|
transcription_error_bad_extension,
|
||||||
TRANSCRIPTION_UPLOADING_TEXT,
|
TRANSCRIPTION_UPLOADING_TEXT,
|
||||||
transcription_success_received,
|
transcription_success_received,
|
||||||
transcription_error_api,
|
system_error_text,
|
||||||
transcription_error_system,
|
|
||||||
UNKNOWN_MAIN_CMD_TEXT,
|
UNKNOWN_MAIN_CMD_TEXT,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 🔌 Импортируем централизованную функцию сбора статистики из main
|
# 🔌 Импортируем централизованную функцию сбора статистики из main
|
||||||
from utils.stats_logger import log_menu_stats
|
from utils.stats_logger import log_menu_stats
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# СИСТЕМА ОТПРАВКИ ОШИБОК НА ПОЧТУ
|
||||||
|
# =========================================================
|
||||||
|
global _last_transcription_email_time
|
||||||
|
_last_transcription_email_time = 0.0
|
||||||
|
|
||||||
|
def _transcription_log_and_notify_email(action: str, error_details: str):
|
||||||
|
global _last_transcription_email_time
|
||||||
|
current_time = time.time()
|
||||||
|
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
logger.info(f"📧 [EMAIL ALERT] Attempting to send alert for: {action}")
|
||||||
|
|
||||||
|
if current_time - _last_transcription_email_time < 10.0:
|
||||||
|
logger.info(f"📧 [EMAIL ALERT] Cooldown active, skipping. Last: {_last_transcription_email_time}")
|
||||||
|
return
|
||||||
|
_last_transcription_email_time = current_time
|
||||||
|
|
||||||
|
try:
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
|
||||||
|
default_from = getattr(config, 'DEFAULT_EMAIL_FROM', 'bot@noreply.com')
|
||||||
|
smtp_server = getattr(config, 'SMTP_SERVER', 'localhost')
|
||||||
|
smtp_port = getattr(config, 'SMTP_PORT', 25)
|
||||||
|
to_emails = getattr(config, 'ALERTS_SUPPORT_EMAILS', ['admin@example.com'])
|
||||||
|
|
||||||
|
logger.info(f"📧 [EMAIL ALERT] From={default_from}, To={to_emails}, SMTP={smtp_server}:{smtp_port}")
|
||||||
|
|
||||||
|
msg['From'] = str(default_from)
|
||||||
|
msg['To'] = ', '.join(to_emails) if isinstance(to_emails, list) else str(to_emails)
|
||||||
|
msg['Subject'] = f'TrueConf Bot Error: Транскрипция - {action}'
|
||||||
|
body = f'Обнаружена ошибка в транскрипции.\nВремя: {timestamp}\nДействие: {action}\nДетали:\n{error_details}'
|
||||||
|
msg.attach(MIMEText(body, 'plain', 'utf-8'))
|
||||||
|
|
||||||
|
logger.info(f"📧 [EMAIL ALERT] Connecting to SMTP...")
|
||||||
|
with smtplib.SMTP(smtp_server, int(smtp_port)) as server:
|
||||||
|
logger.info(f"📧 [EMAIL ALERT] Sending message...")
|
||||||
|
server.send_message(msg)
|
||||||
|
logger.info(f"📧 [EMAIL ALERT] SUCCESS!")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"📧 [EMAIL ALERT] FAILED: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(f"📧 [EMAIL ALERT] Traceback: {traceback.format_exc()}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
@@ -45,13 +95,13 @@ ALLOWED_EXTENSIONS = [
|
|||||||
# --- Функция получения Email из Active Directory ---
|
# --- Функция получения Email из Active Directory ---
|
||||||
def get_user_email_sync(login: str) -> str:
|
def get_user_email_sync(login: str) -> str:
|
||||||
try:
|
try:
|
||||||
server = Server(AD_SERVER, get_info=ALL)
|
from utils.ad_search import search_by_login
|
||||||
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
|
entries = search_by_login(login, ["mail"])
|
||||||
conn.search(search_base=AD_BASE, search_filter=f"(sAMAccountName={login})", attributes=["mail"])
|
if entries and 'mail' in entries[0] and entries[0].mail.value:
|
||||||
if conn.entries and 'mail' in conn.entries[0] and conn.entries[0].mail.value:
|
return str(entries[0].mail.value)
|
||||||
return str(conn.entries[0].mail.value)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка поиска email в AD: {e}")
|
logger.error(f"Ошибка поиска email в AD: {e}")
|
||||||
|
_transcription_log_and_notify_email("Поиск email в AD", str(e))
|
||||||
return DEFAULT_REQUESTER
|
return DEFAULT_REQUESTER
|
||||||
|
|
||||||
@router.message()
|
@router.message()
|
||||||
@@ -116,12 +166,21 @@ async def transcription_handler(msg: Message):
|
|||||||
log_menu_stats(user_id, "Speech-to-Text", f"Отправка встречи на расшифровку ({ext})")
|
log_menu_stats(user_id, "Speech-to-Text", f"Отправка встречи на расшифровку ({ext})")
|
||||||
await msg.answer(transcription_success_received(user_email), parse_mode="html")
|
await msg.answer(transcription_success_received(user_email), parse_mode="html")
|
||||||
else:
|
else:
|
||||||
await msg.answer(transcription_error_api(response.status_code), parse_mode="html")
|
_transcription_log_and_notify_email(f"API транскрипции вернул статус {response.status_code}", str(response.text[:500]))
|
||||||
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Transcription Upload Error: {e}")
|
logger.error(f"Transcription Upload Error: {e}")
|
||||||
await msg.answer(transcription_error_system(), parse_mode="html")
|
_transcription_log_and_notify_email("Загрузка/отправка файла на транскрипцию", str(e))
|
||||||
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- ЗАГЛУШКА НА НЕИЗВЕСТНЫЙ ТЕКСТ / СТАНДАРТНАЯ ОШИБКА ВВОДА ---
|
# --- ЗАГЛУШКА НА НЕИЗВЕСТНЫЙ ТЕКСТ / СТАНДАРТНАЯ ОШИБКА ВВОДА ---
|
||||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,59 @@
|
|||||||
|
# /opt/trueconf_bot/utils/ad_checker.py
|
||||||
|
# Проверка членства пользователя в группах Active Directory
|
||||||
|
|
||||||
|
|
||||||
|
from ldap3 import Server, Connection, ALL
|
||||||
|
from utils.ad_search import search_by_filter as ad_search_all_bases
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_groups(cn: str) -> list[str] | None:
|
||||||
|
"""
|
||||||
|
Найти пользователя по CN и вернуть список групп.
|
||||||
|
Ищет по всем OU из AD_BASES.
|
||||||
|
Возвращает None если пользователь не найден.
|
||||||
|
"""
|
||||||
|
entries = ad_search_all_bases("(cn={})".format(cn), ["cn", "sAMAccountName", "memberOf"])
|
||||||
|
if not entries:
|
||||||
|
return None
|
||||||
|
entry = entries[0]
|
||||||
|
return [str(g) for g in entry.memberOf]
|
||||||
|
|
||||||
|
|
||||||
|
def check_group_membership(cn: str, group_pattern: str = "2FA") -> dict:
|
||||||
|
"""
|
||||||
|
Проверить членство пользователя в группе.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cn: CN пользователя (например 'Krivochenko_Denis_Sergeevich')
|
||||||
|
group_pattern: подстрока для поиска в имени группы (по умолчанию '2FA')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict с полями:
|
||||||
|
found: bool — найден ли пользователь
|
||||||
|
cn: str | None — CN пользователя
|
||||||
|
sam: str | None — sAMAccountName
|
||||||
|
in_group: bool — в группе ли
|
||||||
|
groups: list[str] — все группы
|
||||||
|
matching_groups: list[str] — группы с паттерном
|
||||||
|
"""
|
||||||
|
groups = get_user_groups(cn)
|
||||||
|
if groups is None:
|
||||||
|
return {
|
||||||
|
"found": False,
|
||||||
|
"cn": None,
|
||||||
|
"sam": None,
|
||||||
|
"in_group": False,
|
||||||
|
"groups": [],
|
||||||
|
"matching_groups": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
matching = [g for g in groups if group_pattern.upper() in g.upper()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"found": True,
|
||||||
|
"cn": cn,
|
||||||
|
"sam": groups[0].split(",")[0].split("=")[-1], # грубо из DN
|
||||||
|
"in_group": len(matching) > 0,
|
||||||
|
"groups": groups,
|
||||||
|
"matching_groups": matching,
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# /opt/trueconf_bot/utils/ad_search.py
|
||||||
|
# Универсальный поиск пользователей по нескольким OU в AD.
|
||||||
|
# Каждая функция принимает user_id/email/логин и ищет по всем OU из AD_BASES.
|
||||||
|
# Возвращает первую найденную запись (ldap3 Entry) или None.
|
||||||
|
|
||||||
|
from ldap3 import Server, Connection, ALL
|
||||||
|
from ldap3.utils.conv import escape_filter_chars
|
||||||
|
import config.config as config
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conn():
|
||||||
|
"""Создать соединение с AD."""
|
||||||
|
server = Server(config.AD_SERVER, get_info=ALL)
|
||||||
|
return Connection(server, user=config.AD_USER, password=config.AD_PASSWORD, auto_bind=True)
|
||||||
|
|
||||||
|
|
||||||
|
def search_by_login(login: str, attributes: list[str] | None = None) -> list:
|
||||||
|
"""
|
||||||
|
Найти пользователя по sAMAccountName во всех OU из AD_BASES.
|
||||||
|
Возвращает список Entry (обычно 0 или 1).
|
||||||
|
"""
|
||||||
|
attrs = attributes or ["cn", "sAMAccountName"]
|
||||||
|
conn = _get_conn()
|
||||||
|
for base in config.AD_BASES:
|
||||||
|
conn.search(
|
||||||
|
search_base=base,
|
||||||
|
search_filter="({}={})".format("sAMAccountName", login),
|
||||||
|
attributes=attrs,
|
||||||
|
)
|
||||||
|
if conn.entries:
|
||||||
|
return list(conn.entries)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def search_by_user_id(user_id: str, attributes: list[str] | None = None) -> list:
|
||||||
|
"""
|
||||||
|
Найти пользователя по email/UPN/sAMAccountName во всех OU из AD_BASES.
|
||||||
|
Возвращает список Entry (обычно 0 или 1).
|
||||||
|
"""
|
||||||
|
attrs = attributes or ["cn", "sAMAccountName", "mail", "userPrincipalName", "extensionAttribute2"]
|
||||||
|
safe_id = escape_filter_chars(user_id)
|
||||||
|
short_username = user_id.split("@")[0] if "@" in user_id else user_id
|
||||||
|
safe_sam = escape_filter_chars(short_username)
|
||||||
|
|
||||||
|
# Строим LDAP filter: (&(objectClass=user)(|(mail=...)(userPrincipalName=...)(sAMAccountName=...)))
|
||||||
|
or_parts = [
|
||||||
|
"(mail={})".format(safe_id),
|
||||||
|
"(userPrincipalName={})".format(safe_id),
|
||||||
|
"(userPrincipalName={})".format(short_username + "@sibcem.ru"),
|
||||||
|
"(sAMAccountName={})".format(safe_sam),
|
||||||
|
]
|
||||||
|
or_filter = "(|{})".format("".join(or_parts))
|
||||||
|
search_filter = "(&{}{})".format("(objectClass=user)", or_filter)
|
||||||
|
|
||||||
|
conn = _get_conn()
|
||||||
|
for base in config.AD_BASES:
|
||||||
|
conn.search(search_base=base, search_filter=search_filter, attributes=attrs)
|
||||||
|
if conn.entries:
|
||||||
|
return list(conn.entries)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def search_by_filter(search_filter: str, attributes: list[str]) -> list:
|
||||||
|
"""
|
||||||
|
Найти по произвольному LDAP-фильтру во всех OU из AD_BASES.
|
||||||
|
Возвращает список Entry.
|
||||||
|
"""
|
||||||
|
conn = _get_conn()
|
||||||
|
for base in config.AD_BASES:
|
||||||
|
conn.search(search_base=base, search_filter=search_filter, attributes=attributes)
|
||||||
|
if conn.entries:
|
||||||
|
return list(conn.entries)
|
||||||
|
return []
|
||||||
@@ -62,6 +62,13 @@ def build_dynamic_menu():
|
|||||||
dynamic_items.append(f"{num_char} — Поиск по регламентам")
|
dynamic_items.append(f"{num_char} — Поиск по регламентам")
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
|
if getattr(config, 'ENABLE_INSTRUCT', False):
|
||||||
|
num_char = emoji_digits.get(counter, str(counter))
|
||||||
|
MENU_MAP[str(counter)] = "INSTRUCT"
|
||||||
|
MENU_MAP[num_char] = "INSTRUCT"
|
||||||
|
dynamic_items.append(f"{num_char} — 📋 Инструкции")
|
||||||
|
counter += 1
|
||||||
|
|
||||||
if counter == 1:
|
if counter == 1:
|
||||||
dynamic_items.append("<i>В данный момент бот находится на техническом обслуживании.</i>")
|
dynamic_items.append("<i>В данный момент бот находится на техническом обслуживании.</i>")
|
||||||
|
|
||||||
|
|||||||
+9
-11
@@ -63,18 +63,16 @@ async def send_otp_via_api(phone: str, code: str) -> tuple:
|
|||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
def get_card_id_from_ad(user_id: str) -> str:
|
def get_card_id_from_ad(user_id: str) -> str:
|
||||||
"""Синхронно вытягивает CARD_ID сотрудника из Active Directory (extensionAttribute2)"""
|
"""Синхронно вытягивает CARD_ID сотрудника из Active Directory (extensionAttribute2).
|
||||||
|
Ищет по всем OU из AD_BASES."""
|
||||||
|
from utils.ad_search import search_by_user_id
|
||||||
try:
|
try:
|
||||||
server = Server(config.AD_SERVER, get_info=ALL)
|
entries = search_by_user_id(user_id, ["extensionAttribute2"])
|
||||||
conn = Connection(server, user=config.AD_USER, password=config.AD_PASSWORD, auto_bind=True)
|
if not entries:
|
||||||
safe_user_id = escape_filter_chars(user_id)
|
raise Exception("Пользователь не найден в AD.")
|
||||||
short_username = user_id.split('@')[0]
|
entry = entries[0]
|
||||||
|
if 'extensionAttribute2' in entry and entry.extensionAttribute2.value:
|
||||||
search_filter = f"(&(objectClass=user)(|(mail={safe_user_id})(userPrincipalName={safe_user_id})(userPrincipalName={escape_filter_chars(f'{short_username}@sibcem.ru')})(sAMAccountName={escape_filter_chars(short_username)})))"
|
return str(entry.extensionAttribute2.value).strip()
|
||||||
conn.search(config.AD_BASE, search_filter, attributes=['extensionAttribute2'])
|
|
||||||
|
|
||||||
if conn.entries and 'extensionAttribute2' in conn.entries[0] and conn.entries[0].extensionAttribute2.value:
|
|
||||||
return str(conn.entries[0].extensionAttribute2.value).strip()
|
|
||||||
raise Exception("Поле extensionAttribute2 не заполнено в AD.")
|
raise Exception("Поле extensionAttribute2 не заполнено в AD.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка получения CARD_ID из AD для {user_id}: {e}")
|
logger.error(f"Ошибка получения CARD_ID из AD для {user_id}: {e}")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ PHOTO_MODE = "PHOTO_MODE"
|
|||||||
SD_MODE = "SD_MODE"
|
SD_MODE = "SD_MODE"
|
||||||
TRANSCRIPTION_MODE = "TRANSCRIPTION_MODE"
|
TRANSCRIPTION_MODE = "TRANSCRIPTION_MODE"
|
||||||
SEARCH_MODE = "SEARCH_MODE"
|
SEARCH_MODE = "SEARCH_MODE"
|
||||||
|
INSTRUCT_EMAIL_HISTORY = "INSTRUCT_EMAIL_HISTORY"
|
||||||
|
|
||||||
def set_state(user_id, state):
|
def set_state(user_id, state):
|
||||||
"""Фиксирует новое состояние пользователя и обновляет метку времени активности"""
|
"""Фиксирует новое состояние пользователя и обновляет метку времени активности"""
|
||||||
|
|||||||
+405
-77
@@ -2,18 +2,16 @@
|
|||||||
# Все текстовые сообщения бота, отправляемые пользователю
|
# Все текстовые сообщения бота, отправляемые пользователю
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# GLOBAL CONSTANTS
|
# GLOBAL CONSTANTS & HELPERS
|
||||||
# =========================================================
|
# =========================================================
|
||||||
|
|
||||||
# --- Main Bot ---
|
# --- Main Bot ---
|
||||||
# Общее для главного бота
|
|
||||||
UNKNOWN_MAIN_CMD_TEXT = (
|
UNKNOWN_MAIN_CMD_TEXT = (
|
||||||
"⚠️ <b>Неизвестная команда.</b>\n\n"
|
"⚠️ <b>Неизвестная команда.</b>\n\n"
|
||||||
"Пожалуйста, используйте только цифры, соответствующие нужным пунктам меню, и отправьте выбранную цифру мне в сообщении."
|
"Пожалуйста, используйте только цифры, соответствующие нужным пунктам меню, и отправьте выбранную цифру мне в сообщении."
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Emoji Digits ---
|
# --- Emoji Digits ---
|
||||||
# Используются для навигации и выбора пунктов меню
|
|
||||||
EMOJI_DIGITS = {
|
EMOJI_DIGITS = {
|
||||||
"0": "0⃣",
|
"0": "0⃣",
|
||||||
"1": "1⃣",
|
"1": "1⃣",
|
||||||
@@ -29,7 +27,6 @@ EMOJI_DIGITS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Footer Templates ---
|
# --- Footer Templates ---
|
||||||
# Шаблоны подвалов сообщений для навигации
|
|
||||||
|
|
||||||
def _footer(emojis: dict) -> str:
|
def _footer(emojis: dict) -> str:
|
||||||
"""Стандартный футер с кнопками Назад и Меню"""
|
"""Стандартный футер с кнопками Назад и Меню"""
|
||||||
@@ -39,6 +36,17 @@ def _footer_main_only(emojis: dict) -> str:
|
|||||||
"""Футер только с кнопкой Меню"""
|
"""Футер только с кнопкой Меню"""
|
||||||
return f"\n\n<i>{emojis['0']} — В главное меню</i>"
|
return f"\n\n<i>{emojis['0']} — В главное меню</i>"
|
||||||
|
|
||||||
|
# --- Universal System Error Stub ---
|
||||||
|
|
||||||
|
def system_error_text(emojis: dict = EMOJI_DIGITS, show_back: bool = True) -> str:
|
||||||
|
"""Универсальная системная заглушка ошибки для всех модулей бота."""
|
||||||
|
footer = _footer(emojis) if show_back else _footer_main_only(emojis)
|
||||||
|
return (
|
||||||
|
"⚠️ <i>В данный момент выполнить это действие невозможно. "
|
||||||
|
"Информация о проблеме передана ответственным.\n"
|
||||||
|
f"Приносим извинения за доставленные неудобства.</i>{footer}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# TRANSCRIPTION BOT
|
# TRANSCRIPTION BOT
|
||||||
@@ -140,16 +148,14 @@ def _sd_ticket_created(ticket_id: int, subject: str, description: str, emojis: d
|
|||||||
clean_desc = clean_desc[:147] + "..."
|
clean_desc = clean_desc[:147] + "..."
|
||||||
return (
|
return (
|
||||||
f"✅ <b>Заявка #{ticket_id} создана!</b>\n\n"
|
f"✅ <b>Заявка #{ticket_id} создана!</b>\n\n"
|
||||||
|
|
||||||
f"📌 <b>Тема: {subject}</b>\n\n"
|
f"📌 <b>Тема: {subject}</b>\n\n"
|
||||||
f"📝 <b>Текст: {clean_desc}</b>\n\n"
|
f"📝 <b>Текст: {clean_desc}</b>\n\n"
|
||||||
|
|
||||||
f"Если нужно добавить скриншоты или документ — отправьте их сейчас.\n\n"
|
f"Если нужно добавить скриншоты или документ — отправьте их сейчас.\n\n"
|
||||||
f"{_footer(emojis)}"
|
f"{_footer(emojis)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def sd_ticket_created(ticket_id: int, subject: str, description: str) -> str:
|
def _sd_ticket_create_error(emojis: dict) -> str:
|
||||||
return _sd_ticket_created(ticket_id, subject, description, EMOJI_DIGITS)
|
return f"❌ <b>Ошибка создания заявки.</b>{_footer_main_only(emojis)}"
|
||||||
|
|
||||||
def _sd_system_error(emojis: dict) -> str:
|
def _sd_system_error(emojis: dict) -> str:
|
||||||
return f"❌ <b>Системная ошибка.</b> Попробуйте позже.{_footer_main_only(emojis)}"
|
return f"❌ <b>Системная ошибка.</b> Попробуйте позже.{_footer_main_only(emojis)}"
|
||||||
@@ -170,10 +176,8 @@ SD_UNKNOWN_CMD_TEXT = _sd_unknown(EMOJI_DIGITS)
|
|||||||
SD_TEXT_REQUIRED = _sd_text_required(EMOJI_DIGITS)
|
SD_TEXT_REQUIRED = _sd_text_required(EMOJI_DIGITS)
|
||||||
SD_CREATING = _sd_creating()
|
SD_CREATING = _sd_creating()
|
||||||
|
|
||||||
def _sd_ticket_create_error(emojis: dict) -> str:
|
def sd_ticket_created(ticket_id: int, subject: str, description: str) -> str:
|
||||||
return (
|
return _sd_ticket_created(ticket_id, subject, description, EMOJI_DIGITS)
|
||||||
f"❌ <b>Не удалось создать заявку.</b>\n\n" f"Пожалуйста, повторите попытку позже.\n\n" f"{emojis['0']} — В главное меню"
|
|
||||||
)
|
|
||||||
|
|
||||||
def sd_ticket_create_error() -> str:
|
def sd_ticket_create_error() -> str:
|
||||||
return _sd_ticket_create_error(EMOJI_DIGITS)
|
return _sd_ticket_create_error(EMOJI_DIGITS)
|
||||||
@@ -194,16 +198,31 @@ def sd_file_upload_error() -> str:
|
|||||||
# =========================================================
|
# =========================================================
|
||||||
# PHOTO BOT
|
# PHOTO BOT
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# Модуль для обновления фото профиля
|
# Модуль для обновления фото профиля — интеграция с Django API
|
||||||
|
|
||||||
# --- Private functions (Internal logic) ---
|
# --- Private functions (Internal logic) ---
|
||||||
|
|
||||||
def _photo_welcome(emojis: dict) -> str:
|
def _photo_welcome(emojis: dict, show_test: bool = False, info: str = None, pending: bool = False) -> str:
|
||||||
return (
|
"""Входное сообщение фото-режима. info — необязательный блок о статусе
|
||||||
f"🖼 <b>Режим обновления фото профиля</b>\n\n"
|
модерации (отказ). pending=True — фото уже на модерации: без призыва
|
||||||
f"📸 Пришлите мне вашу фотографию.\n\n"
|
отправить фото, только статус."""
|
||||||
f"{emojis['0']} — В главное меню"
|
text = f"🖼 <b>Режим обновления фото профиля</b>\n\n"
|
||||||
)
|
if pending:
|
||||||
|
text += (
|
||||||
|
f"⏳ <b>У вас уже есть фото на модерации.</b>\n"
|
||||||
|
f"Ожидайте решения модератора.\n\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text += (
|
||||||
|
f"📸 Пришлите мне вашу фотографию.\n\n"
|
||||||
|
f"Бот автоматически найдёт лицо, покажет превью\n"
|
||||||
|
f"и отправит фото на публикацию в ваши корпоративные сервисы.\n\n"
|
||||||
|
)
|
||||||
|
if info:
|
||||||
|
text += info + "\n\n"
|
||||||
|
if show_test:
|
||||||
|
text += f"{emojis['1']} — тест проверка фото\n"
|
||||||
|
return text + f"{emojis['0']} — В главное меню"
|
||||||
|
|
||||||
def _photo_unknown(emojis: dict) -> str:
|
def _photo_unknown(emojis: dict) -> str:
|
||||||
return (
|
return (
|
||||||
@@ -213,19 +232,203 @@ def _photo_unknown(emojis: dict) -> str:
|
|||||||
f"{emojis['0']} — В главное меню"
|
f"{emojis['0']} — В главное меню"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _photo_error_handling(emojis: dict) -> str:
|
def _photo_processing_face(emojis: dict) -> str:
|
||||||
|
return "⏳ <i>Ищу лицо на фото, пожалуйста, подождите...</i>"
|
||||||
|
|
||||||
|
def _photo_preview_ready(emojis: dict) -> str:
|
||||||
return (
|
return (
|
||||||
f"⚠️ <i>В данный момент выполнить это действие невозможно. "
|
f"📸 Вот ваш кадр.\n\n"
|
||||||
f"Информация о проблеме передана ответственным.\n"
|
f"{emojis['1']} — Подтвердить\n"
|
||||||
f"Приносим извинения за доставленные неудобства.</i>\n\n"
|
f"{emojis['2']} — Вырезать заново\n\n"
|
||||||
f"{emojis['0']} — В главное меню"
|
f"{emojis['0']} — В главное меню"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _photo_uploading() -> str:
|
def _photo_ai_processing(emojis: dict) -> str:
|
||||||
return "⏳ Отправка..."
|
return (
|
||||||
|
"⏳ <i>ИИ обрабатывает фото. Это может занять до 5 минут.\n"
|
||||||
|
"Пожалуйста, не закрывайте чат и подождите...</i>"
|
||||||
|
)
|
||||||
|
|
||||||
def _photo_success_sent() -> str:
|
def _photo_status_info(status: dict = None, emojis: dict = None) -> str | None:
|
||||||
return "📩 <b>Фото отправлено на утверждение.</b>"
|
"""Блок о статусе модерации для входного сообщения. Универсальный для всех
|
||||||
|
случаев: rejected → причина отказа, pending → ожидание, прочее → пусто."""
|
||||||
|
if not status or status.get("status") != "ok":
|
||||||
|
return None
|
||||||
|
ms = status.get("moderation_status")
|
||||||
|
if ms == "rejected":
|
||||||
|
reason = (status.get("rejection_reason") or "Причина не указана.").strip()
|
||||||
|
return (
|
||||||
|
f"⚠️ <b>Ваше предыдущее фото было отклонено.</b>\n"
|
||||||
|
f"📝 Причина: {reason}\n\n"
|
||||||
|
f"Пожалуйста, отправьте новое фото."
|
||||||
|
)
|
||||||
|
if ms == "pending":
|
||||||
|
return (
|
||||||
|
f"⏳ <b>У вас уже есть фото на модерации.</b>\n"
|
||||||
|
f"Ожидайте решения модератора."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _photo_test_welcome(emojis: dict) -> str:
|
||||||
|
"""Тест-приветствие: фото уйдёт напрямую в ИИ, никуда не публикуется."""
|
||||||
|
return (
|
||||||
|
f"🧪 <b>Тестовый режим фото-проверки</b>\n\n"
|
||||||
|
f"📸 Пришлите фотографию — она уйдёт <b>напрямую в ИИ</b>.\n"
|
||||||
|
f"Бот покажет вердикт ИИ. <b>Фото никуда не публикуется</b>.\n\n"
|
||||||
|
f"{emojis['9']} — Назад\n"
|
||||||
|
f"{emojis['0']} — В главное меню"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _photo_test_preview(emojis: dict) -> str:
|
||||||
|
return (
|
||||||
|
f"🧪 <b>Тестовый режим</b>\n\n"
|
||||||
|
f"Кадр получен. Фото никуда не публикуется.\n\n"
|
||||||
|
f"{emojis['1']} — отправлю фото на проверку по сайту\n"
|
||||||
|
f"{emojis['2']} — Загрузить другое фото\n"
|
||||||
|
f"{emojis['9']} — Назад\n"
|
||||||
|
f"{emojis['0']} — В главное меню"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _photo_test_ai_checking(emojis: dict) -> str:
|
||||||
|
return (
|
||||||
|
"🧪 <i>Отправляю фото на проверку по сайту (тестовый режим)...\n"
|
||||||
|
"Фото проходят полный контроль: CV, гримасы, ИИ.\n"
|
||||||
|
"Обычно это занимает 1-3 минуты. Пожалуйста, подождите...</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _photo_test_receiving(emojis: dict) -> str:
|
||||||
|
return "🧪 <i>Ищу лицо на фото (тестовый режим), пожалуйста, подождите...</i>"
|
||||||
|
|
||||||
|
def _photo_test_result(verdict: str, reason: str, cv_approved=None, cv_reason: str = '',
|
||||||
|
forced=None, timings: dict = None, ai_metrics: dict = None,
|
||||||
|
frame: dict = None, emojis: dict = None) -> str:
|
||||||
|
emojis = emojis or {}
|
||||||
|
icons = {"Прошел": "✅", "Не прошел": "❌", "На модерацию": "⏳"}
|
||||||
|
icon = icons.get(verdict, "ℹ️")
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
"🧪 <b>Тестовый режим: результат проверки</b>\n",
|
||||||
|
f"{icon} <b>Вердикт:</b> {verdict}\n",
|
||||||
|
]
|
||||||
|
if reason:
|
||||||
|
parts.append(f"📝 <b>Причина (ИИ):</b> {reason}\n")
|
||||||
|
|
||||||
|
# Детерминированный слой (MediaPipe CV + ARKit-формы)
|
||||||
|
if cv_approved is True:
|
||||||
|
line = "🎯 <b>CV-проверка:</b> ✅ пройдена"
|
||||||
|
if cv_reason:
|
||||||
|
line += f" — <i>{cv_reason}</i>"
|
||||||
|
parts.append(line + "\n")
|
||||||
|
elif cv_approved is False:
|
||||||
|
parts.append("🎯 <b>CV-проверка:</b> ❌ отклонена\n")
|
||||||
|
if cv_reason:
|
||||||
|
parts.append(f"↳ <i>{cv_reason}</i>\n")
|
||||||
|
else:
|
||||||
|
parts.append("🎯 <b>CV-проверка:</b> —\n")
|
||||||
|
|
||||||
|
# Метрики кадра, которые реально измерил сайт (виден именно проверенный кадр)
|
||||||
|
if frame:
|
||||||
|
bb = frame.get('face_bbox_px') or {}
|
||||||
|
try:
|
||||||
|
box = f"[{bb.get('x1')}..{bb.get('x2')}]×[{bb.get('y1')}..{bb.get('y2')}]"
|
||||||
|
parts.append(
|
||||||
|
f"📐 <b>Кадр (измерено сайтом):</b> {frame.get('img_w')}×{frame.get('img_h')} px, "
|
||||||
|
f"бокс лица {box}, голова <b>{frame.get('head_pct')}%</b> "
|
||||||
|
f"(норма ≥ {int(frame.get('min_head_percent', 30))}%)\n"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if forced:
|
||||||
|
parts.append("ℹ️ <i>На сайте включён режим принудительной модерации: в реальном режиме фото ушло бы модератору, а не было опубликовано сразу.</i>\n")
|
||||||
|
|
||||||
|
# Тайминги этапов
|
||||||
|
if timings:
|
||||||
|
names = [
|
||||||
|
('cv', "CV (геометрия)"),
|
||||||
|
('expression', "Гримасы (ARKit-формы)"),
|
||||||
|
('ai', "ИИ (llama.cpp)"),
|
||||||
|
('total', "Всего"),
|
||||||
|
]
|
||||||
|
tl = ["📊 <b>Технические показатели (по этапам)</b>"]
|
||||||
|
for k, name in names:
|
||||||
|
v = timings.get(k)
|
||||||
|
if v is not None:
|
||||||
|
tl.append(f"⏱ {name}: <b>{float(v):.2f} сек</b>")
|
||||||
|
if len(tl) > 1:
|
||||||
|
parts.append("\n".join(tl) + "\n")
|
||||||
|
|
||||||
|
# Токены и скорость (сайт передаёт их из ответа llama.cpp)
|
||||||
|
if ai_metrics:
|
||||||
|
ml = ["🔢 <b>ИИ: токены и скорость</b>"]
|
||||||
|
pt = ai_metrics.get('prompt_tokens')
|
||||||
|
gps = ai_metrics.get('prompt_per_second')
|
||||||
|
if pt is not None:
|
||||||
|
s = f"⬇️ Приём (промпт): <b>{pt} токенов</b>"
|
||||||
|
if gps is not None:
|
||||||
|
s += f" — {float(gps):.1f} ток/сек"
|
||||||
|
ml.append(s)
|
||||||
|
ct = ai_metrics.get('completion_tokens')
|
||||||
|
ggs = ai_metrics.get('predicted_per_second')
|
||||||
|
if ct is not None:
|
||||||
|
s = f"⬆️ Отдача (генерация): <b>{ct} токенов</b>"
|
||||||
|
if ggs is not None:
|
||||||
|
s += f" — {float(ggs):.1f} ток/сек"
|
||||||
|
ml.append(s)
|
||||||
|
if len(ml) > 1:
|
||||||
|
parts.append("\n".join(ml) + "\n")
|
||||||
|
|
||||||
|
parts.append("⚠️ <i>Тестовый режим: фото не публиковалось и никуда не отправлялось.</i>\n\n")
|
||||||
|
parts.append(f"{emojis.get('2', '2')} — Загрузить другое фото\n")
|
||||||
|
parts.append(f"{emojis.get('9', '9')} — Назад\n")
|
||||||
|
parts.append(f"{emojis.get('0', '0')} — В главное меню")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
def _photo_test_ai_error(emojis: dict, error: str = '', elapsed: float = None) -> str:
|
||||||
|
detail = ''
|
||||||
|
err = (error or '').strip()
|
||||||
|
if len(err) > 400:
|
||||||
|
err = err[:400] + '…'
|
||||||
|
if err:
|
||||||
|
detail += f"📋 <b>Детали:</b> <i>{err}</i>\n"
|
||||||
|
if elapsed is not None:
|
||||||
|
detail += f"⏱ Время до ошибки: <b>{elapsed:.1f} сек</b>\n"
|
||||||
|
return (
|
||||||
|
f"❌ <b>Ошибка проверки фото.</b>\n\n"
|
||||||
|
f"{detail}\n"
|
||||||
|
f"Сервис проверки временно недоступен. Повторите попытку позже.\n\n"
|
||||||
|
f"{emojis['1']} — Повторить проверку\n"
|
||||||
|
f"{emojis['2']} — Загрузить другое фото\n"
|
||||||
|
f"{emojis['0']} — В главное меню"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _photo_success_published(emojis: dict) -> str:
|
||||||
|
return (
|
||||||
|
f"🎉 <b>Фото успешно опубликовано!</b>\n\n"
|
||||||
|
f"Применение во всех системах может занять до 2-х суток.\n\n"
|
||||||
|
f"{emojis['0']} — В главное меню"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _photo_success_pending(emojis: dict) -> str:
|
||||||
|
return (
|
||||||
|
f"📋 <b>Фото отправлено на модерацию.</b>\n\n"
|
||||||
|
f"Ожидайте уведомления.\n\n"
|
||||||
|
f"{emojis['0']} — В главное меню"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _photo_error_rejected(reason: str, emojis: dict) -> str:
|
||||||
|
return (
|
||||||
|
f"⚠️ <b>Фото отклонено:</b> {reason}\n\n"
|
||||||
|
f"Пожалуйста, отправьте другое фото.\n\n"
|
||||||
|
f"{emojis['0']} — В главное меню"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _photo_error_already_pending(emojis: dict) -> str:
|
||||||
|
return (
|
||||||
|
f"⚠️ <b>У вас уже есть фото на модерации.</b>\n\n"
|
||||||
|
f"Подождите завершения текущей обработки.\n\n"
|
||||||
|
f"{emojis['0']} — В главное меню"
|
||||||
|
)
|
||||||
|
|
||||||
def _photo_cancel(emojis: dict) -> str:
|
def _photo_cancel(emojis: dict) -> str:
|
||||||
return (
|
return (
|
||||||
@@ -233,47 +436,65 @@ def _photo_cancel(emojis: dict) -> str:
|
|||||||
f"{emojis['0']} — В главное меню"
|
f"{emojis['0']} — В главное меню"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _photo_processing() -> str:
|
|
||||||
return "⏳ Обработка..."
|
|
||||||
|
|
||||||
def _photo_preview_ready(emojis: dict) -> str:
|
|
||||||
return (
|
|
||||||
f"... <b>Фото готово!</b>\n\n"
|
|
||||||
f"{emojis['1']} — Отправить на утверждение\n"
|
|
||||||
f"{emojis['2']} — Попробовать другое\n\n"
|
|
||||||
f"{emojis['0']} — В главное меню"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _photo_error_processing(error_text: str, emojis: dict) -> str:
|
|
||||||
return (
|
|
||||||
f"❌ <b>Ошибка:</b> {error_text}\n\n"
|
|
||||||
f"Пожалуйста, отправьте другое фото.\n\n"
|
|
||||||
f"{emojis['0']} — В главное меню"
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Public constants and functions ---
|
# --- Public constants and functions ---
|
||||||
|
|
||||||
PHOTO_MAIN_MENU_TEXT = _photo_welcome(EMOJI_DIGITS)
|
PHOTO_MAIN_MENU_TEXT = _photo_welcome(EMOJI_DIGITS)
|
||||||
|
PHOTO_TEST_ENTRY_TEXT = _photo_welcome(EMOJI_DIGITS, show_test=True)
|
||||||
PHOTO_UNKNOWN_CMD_TEXT = _photo_unknown(EMOJI_DIGITS)
|
PHOTO_UNKNOWN_CMD_TEXT = _photo_unknown(EMOJI_DIGITS)
|
||||||
|
|
||||||
def photo_error_handling() -> str:
|
def photo_error_handling() -> str:
|
||||||
return _photo_error_handling(EMOJI_DIGITS)
|
return system_error_text(EMOJI_DIGITS, show_back=False)
|
||||||
|
|
||||||
PHOTO_UPLOADING_TEXT = _photo_uploading()
|
PHOTO_PROCESSING_FACE_TEXT = _photo_processing_face(EMOJI_DIGITS)
|
||||||
|
|
||||||
def photo_success_sent() -> str:
|
|
||||||
return _photo_success_sent()
|
|
||||||
|
|
||||||
def photo_cancel_text() -> str:
|
|
||||||
return _photo_cancel(EMOJI_DIGITS)
|
|
||||||
|
|
||||||
PHOTO_PROCESSING_TEXT = _photo_processing()
|
|
||||||
|
|
||||||
def photo_preview_ready() -> str:
|
def photo_preview_ready() -> str:
|
||||||
return _photo_preview_ready(EMOJI_DIGITS)
|
return _photo_preview_ready(EMOJI_DIGITS)
|
||||||
|
|
||||||
def photo_error_processing(error_text: str) -> str:
|
PHOTO_AI_PROCESSING_TEXT = _photo_ai_processing(EMOJI_DIGITS)
|
||||||
return _photo_error_processing(error_text, EMOJI_DIGITS)
|
|
||||||
|
def photo_success_published() -> str:
|
||||||
|
return _photo_success_published(EMOJI_DIGITS)
|
||||||
|
|
||||||
|
def photo_success_pending() -> str:
|
||||||
|
return _photo_success_pending(EMOJI_DIGITS)
|
||||||
|
|
||||||
|
def photo_error_rejected(reason: str) -> str:
|
||||||
|
return _photo_error_rejected(reason, EMOJI_DIGITS)
|
||||||
|
|
||||||
|
def photo_error_already_pending() -> str:
|
||||||
|
return _photo_error_already_pending(EMOJI_DIGITS)
|
||||||
|
|
||||||
|
def photo_cancel_text() -> str:
|
||||||
|
return _photo_cancel(EMOJI_DIGITS)
|
||||||
|
|
||||||
|
def photo_welcome_with_status(status: dict = None, show_test: bool = False) -> str:
|
||||||
|
"""Универсальное входное сообщение фото-режима: меню + статус модерации.
|
||||||
|
pending — только статус (без призыва отправить фото);
|
||||||
|
show_test — добавить кнопку тест-проверки (PHOTO_TEST_MODE)."""
|
||||||
|
info = _photo_status_info(status, EMOJI_DIGITS)
|
||||||
|
pending = bool(
|
||||||
|
status and status.get("status") == "ok"
|
||||||
|
and status.get("moderation_status") == "pending"
|
||||||
|
)
|
||||||
|
return _photo_welcome(EMOJI_DIGITS, show_test=show_test, info=info, pending=pending)
|
||||||
|
|
||||||
|
PHOTO_TEST_WELCOME_TEXT = _photo_test_welcome(EMOJI_DIGITS)
|
||||||
|
|
||||||
|
def photo_test_welcome() -> str:
|
||||||
|
"""Тест-приветствие (тест-под-режим фото-проверки)."""
|
||||||
|
return _photo_test_welcome(EMOJI_DIGITS)
|
||||||
|
PHOTO_TEST_PREVIEW_TEXT = _photo_test_preview(EMOJI_DIGITS)
|
||||||
|
PHOTO_TEST_AI_CHECKING_TEXT = _photo_test_ai_checking(EMOJI_DIGITS)
|
||||||
|
PHOTO_TEST_RECEIVING_TEXT = _photo_test_receiving(EMOJI_DIGITS)
|
||||||
|
|
||||||
|
def photo_test_result(verdict: str, ai_reason: str = '', cv_approved=None, cv_reason: str = '',
|
||||||
|
forced=None, timings: dict = None, ai_metrics: dict = None,
|
||||||
|
frame: dict = None) -> str:
|
||||||
|
return _photo_test_result(verdict, ai_reason, cv_approved, cv_reason,
|
||||||
|
forced, timings, ai_metrics, frame, EMOJI_DIGITS)
|
||||||
|
|
||||||
|
def photo_test_ai_error(error: str = '') -> str:
|
||||||
|
return _photo_test_ai_error(EMOJI_DIGITS, error)
|
||||||
|
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -323,14 +544,6 @@ def _search_feedback_info(emojis: dict) -> str:
|
|||||||
f"{emojis['0']} — Выйти в главное меню"
|
f"{emojis['0']} — Выйти в главное меню"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _search_unavailable(emojis: dict) -> str:
|
|
||||||
return (
|
|
||||||
"⚠️ <i>В данный момент выполнить это действие невозможно. "
|
|
||||||
"Информация о проблеме передана ответственным.\n"
|
|
||||||
"Приносим извинения за доставленные неудобства.</i>\n\n"
|
|
||||||
f"<i>{emojis['0']} — В главное меню</i>"
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Public constants and functions ---
|
# --- Public constants and functions ---
|
||||||
|
|
||||||
SEARCH_MAIN_MENU_TEXT = _search_welcome(EMOJI_DIGITS)
|
SEARCH_MAIN_MENU_TEXT = _search_welcome(EMOJI_DIGITS)
|
||||||
@@ -349,7 +562,7 @@ def search_feedback_info_text(emojis: dict) -> str:
|
|||||||
return _search_feedback_info(emojis)
|
return _search_feedback_info(emojis)
|
||||||
|
|
||||||
def search_unavailable_action(emojis: dict) -> str:
|
def search_unavailable_action(emojis: dict) -> str:
|
||||||
return _search_unavailable(emojis)
|
return system_error_text(emojis, show_back=False)
|
||||||
|
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -357,8 +570,6 @@ def search_unavailable_action(emojis: dict) -> str:
|
|||||||
# =========================================================
|
# =========================================================
|
||||||
# Модуль Личного Кабинета (финансовые документы, табель и др.)
|
# Модуль Личного Кабинета (финансовые документы, табель и др.)
|
||||||
|
|
||||||
# --- Constants ---
|
|
||||||
|
|
||||||
DOC_TYPES = {
|
DOC_TYPES = {
|
||||||
"1": "Справка о доходах физического лица (2-НДФЛ)",
|
"1": "Справка о доходах физического лица (2-НДФЛ)",
|
||||||
"2": "Справка об удержаниях за ДМС",
|
"2": "Справка об удержаниях за ДМС",
|
||||||
@@ -371,12 +582,6 @@ DOC_TYPES = {
|
|||||||
|
|
||||||
# --- Private functions (Internal logic) ---
|
# --- 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:
|
def _lk_auth_processing(emojis: dict) -> str:
|
||||||
return "⏳ <i>Проверка безопасности... Запрашиваю контакты авторизации...</i>"
|
return "⏳ <i>Проверка безопасности... Запрашиваю контакты авторизации...</i>"
|
||||||
|
|
||||||
@@ -400,7 +605,7 @@ def _lk_payslip_loading(emojis: dict) -> str:
|
|||||||
return "⏳ <i>Формирую архив расчетных листков...</i>"
|
return "⏳ <i>Формирую архив расчетных листков...</i>"
|
||||||
|
|
||||||
def _lk_vacation_loading(emojis: dict) -> str:
|
def _lk_vacation_loading(emojis: dict) -> str:
|
||||||
return "⏳ <i>Формирую справку по отпусках...</i>"
|
return "⏳ <i>Формирую справку по отпускам...</i>"
|
||||||
|
|
||||||
def _lk_sick_loading(emojis: dict) -> str:
|
def _lk_sick_loading(emojis: dict) -> str:
|
||||||
return "⏳ <i>Загружаю историю больничных листов...</i>"
|
return "⏳ <i>Загружаю историю больничных листов...</i>"
|
||||||
@@ -464,6 +669,15 @@ def _lk_docs_quantity_prompt(emojis: dict) -> str:
|
|||||||
def _lk_docs_year_prompt(emojis: dict) -> str:
|
def _lk_docs_year_prompt(emojis: dict) -> str:
|
||||||
return f"📅 <b>Выберите год, за который требуется справка:</b>\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
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:
|
def _lk_docs_avg_salary_prompt(emojis: dict) -> str:
|
||||||
return f"Укажите количество месяцев для расчета средней ЗП (например: 3):\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
return f"Укажите количество месяцев для расчета средней ЗП (например: 3):\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
|
|
||||||
@@ -495,7 +709,7 @@ LK_UNKNOWN_CMD_TEXT = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
def lk_error_handling(emojis: dict) -> str:
|
def lk_error_handling(emojis: dict) -> str:
|
||||||
return _lk_error_handling(emojis)
|
return system_error_text(emojis, show_back=True)
|
||||||
|
|
||||||
def lk_auth_processing(emojis: dict) -> str:
|
def lk_auth_processing(emojis: dict) -> str:
|
||||||
return _lk_auth_processing(emojis)
|
return _lk_auth_processing(emojis)
|
||||||
@@ -573,4 +787,118 @@ def lk_nav_text(emojis: dict) -> str:
|
|||||||
return _lk_nav_text(emojis)
|
return _lk_nav_text(emojis)
|
||||||
|
|
||||||
def lk_years_menu(current_year: int, emojis: dict) -> str:
|
def lk_years_menu(current_year: int, emojis: dict) -> str:
|
||||||
return lk_years_menu(current_year, emojis)
|
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_pending(emojis: dict, cn: str) -> str:
|
||||||
|
"""Промпт для запроса доступа к Trueconf (когда заявка ещё не создана)."""
|
||||||
|
return (
|
||||||
|
f"⚠ <b>У Вас отсутствует доступ к TrueConf с мобильных устройств.</b>\n\n"
|
||||||
|
f"Если Вам требуется доступ, то напишите цифру {emojis['1']}, либо выберите другие пункты меню.\n\n"
|
||||||
|
f"{emojis['1']} — Запросить доступ\n"
|
||||||
|
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _instruct_trueconf_no_ticket(emojis: dict, cn: str) -> str:
|
||||||
|
"""Текст Trueconf, когда пользователь не найден в AD."""
|
||||||
|
return (
|
||||||
|
f"🖥 <b>Инструкция по TrueConf</b>\n\n"
|
||||||
|
f"Пользователь <b>{cn}</b> не найден в Active Directory.\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:
|
||||||
|
- Если ticket_id передан: показываем статус заявки в ServiceDesk.
|
||||||
|
- Если ticket_id is None: у пользователя есть доступ, выводим подпись к файлу.
|
||||||
|
"""
|
||||||
|
if ticket_id:
|
||||||
|
return (
|
||||||
|
f"⚠ <b>У Вас отсутствует доступ к TrueConf с мобильных устройств.</b>\n\n"
|
||||||
|
f"🎫 Заявка #{ticket_id} для настройки создана в ServiceDesk!\n\n"
|
||||||
|
f"После выполнения заявки воспользуйтесь приложенной к этому сообщению инструкцией.\n\n"
|
||||||
|
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
f"🖥 <b>Инструкция по настройке TrueConf на мобильном устройстве</b>\n\n"
|
||||||
|
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _instruct_email_available(emojis: dict, cn: str, mail: str) -> str:
|
||||||
|
"""Текст когда почта доступна."""
|
||||||
|
return (
|
||||||
|
f"📧 <b>Инструкция по настройке Почты на мобильном устройстве</b>\n\n"
|
||||||
|
|
||||||
|
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _instruct_email_history_ask(emojis: dict, cn: str) -> str:
|
||||||
|
"""Вопрос о наличии ранее доступа к мобильной почте."""
|
||||||
|
return (
|
||||||
|
f"📧 <b>Был ли у Вас ранее доступ к мобильной почте?</b>\n\n"
|
||||||
|
f"Напишите нужную цифру в ответ:\n\n"
|
||||||
|
f"{emojis['1']} — да, был\n"
|
||||||
|
f"{emojis['2']} — нет, не было\n\n"
|
||||||
|
f"{emojis['0']} — В главное меню"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _instruct_email_unavailable(emojis: dict, cn: str) -> str:
|
||||||
|
"""Текст когда почта недоступна (старый вариант для обратной совместимости)."""
|
||||||
|
return (
|
||||||
|
f"⚠️ <b>У Вас отсутствует доступ к Почте</b>\n\n"
|
||||||
|
"Создайте, пожалуйста, заявку в Directum RX на модификацию прав пользователя.\n\n"
|
||||||
|
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _instruct_email_not_had(emojis: dict, cn: str) -> str:
|
||||||
|
"""Текст когда пользователь ответил 'не было доступа к мобильной почте'."""
|
||||||
|
return (
|
||||||
|
f"⚠️ <b>У Вас отсутствует доступ к мобильной почте.</b>\n\n"
|
||||||
|
"Создайте, пожалуйста, заявку в Directum RX на \"Удалённый доступ к ИС\".\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_TRUECONF_PENDING_TEXT = _instruct_trueconf_pending
|
||||||
|
INSTRUCT_TRUECONF_NO_TICKET_TEXT = _instruct_trueconf_no_ticket
|
||||||
|
INSTRUCT_EMAIL_AVAILABLE = _instruct_email_available
|
||||||
|
INSTRUCT_EMAIL_HISTORY_ASK = _instruct_email_history_ask
|
||||||
|
INSTRUCT_EMAIL_NOT_HAD = _instruct_email_not_had
|
||||||
|
INSTRUCT_EMAIL_UNAVAILABLE = _instruct_email_unavailable
|
||||||
|
|
||||||
|
# Единая стандартная заглушка системной ошибки для модуля инструкций
|
||||||
|
INSTRUCT_ERROR_MSG = system_error_text(EMOJI_DIGITS, show_back=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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