27 Commits

Author SHA1 Message Date
dddennnisss 0b76fbaf17 Добавить файл инструкции Trueconf.docx в ответ бота 2026-07-23 01:20:56 +07:00
dddennnisss f4c21c825c Загрузить файлы в «instruct» 2026-07-23 01:08:54 +07:00
Денис Кривоченко 3fa7543477 fix: LDAP filter malformed — добавлен символ '|' в or_filter
search_by_user_id: or_filter был '(...)' вместо '(|...)' — LDAP требовал OR-оператор
2026-07-22 18:53:34 +07:00
Денис Кривоченко 064451dc7c add: check_card_id.py — проверка user_id -> CARD_ID -> телефон
Использует AD_BASES через utils.ad_search.search_by_user_id
2026-07-22 18:32:35 +07:00
Денис Кривоченко c76914444c fix: грамматика 'справка по отпускам' (было 'по отпусках') 2026-07-22 16:56:02 +07:00
Денис Кривоченко 09b19f9ced AD: поиск по нескольким OU (AD_BASES)
- config.py: AD_BASE -> AD_BASES (кортеж OU)
- utils/ad_search.py: новая утилита для поиска по всем OU
- utils/ad_checker.py: использовать ad_search
- utils/otp_service.py: использовать ad_search
- lk/handlers.py: использовать ad_search
- transcription_bot/handlers.py: использовать ad_search
- service_desk/handlers.py: использовать ad_search
- instruct/handlers.py: использовать ad_search
- photo_bot/ad_helper.py: использовать ad_search
- photo_bot/check_ad_photo.py: использовать ad_search
- check_ad_2fa.py: использовать ad_search
- debug_ad_filter.py: обновить для AD_BASES
- lk/test_lk_payslip.py: обновить для AD_BASES
2026-07-22 16:52:17 +07:00
Денис Кривоченко 9f76064826 fix: remove extra closing paren in LDAP filter 2026-07-22 00:49:21 +07:00
Денис Кривоченко 49a986fa65 fix: don't escape @ in email for LDAP filter 2026-07-22 00:41:53 +07:00
Денис Кривоченко 2c39e5b50b fix: use lk/handlers.py filter format, add debug script 2026-07-22 00:41:26 +07:00
Денис Кривоченко 26b58f2909 fix: search AD in full domain tree + debug logging 2026-07-22 00:34:40 +07:00
Денис Кривоченко d9528486ef fix: clean duplicate logging, use proper AD lookup with debug 2026-07-22 00:33:45 +07:00
Денис Кривоченко 525899557b add debug logging to AD lookup 2026-07-22 00:32:41 +07:00
Денис Кривоченко 03339885ff fix: replace tcs.sibcem.ru with sibcem.ru in AD user lookup 2026-07-22 00:02:16 +07:00
Денис Кривоченко 8fa42206aa add AD 2FA group check to instruct Trueconf module 2026-07-21 23:47:35 +07:00
Денис Кривоченко be12045fa8 add check_ad_2fa.py - interactive AD group membership checker 2026-07-21 23:27:36 +07:00
Денис Кривоченко 7c0aaf5392 fix: set state on instruct menu selection so back button works 2026-07-21 22:48:39 +07:00
Денис Кривоченко 55a95889b0 import EMOJI_DIGITS from utils/texts.py 2026-07-21 22:44:54 +07:00
Денис Кривоченко 8029f7fb2d move instruct text constants to utils/texts.py 2026-07-21 22:40:08 +07:00
Денис Кривоченко 2fa4ff2781 fix: replace <br> with real newlines for HTML parse mode 2026-07-21 22:36:03 +07:00
Денис Кривоченко aadbaab321 use UNKNOWN_MAIN_CMD_TEXT for instruct module (unified error style) 2026-07-21 22:32:38 +07:00
Денис Кривоченко 54f0d074ba unify INSTRUCT_UNKNOWN_CMD_TEXT with other modules 2026-07-21 22:28:18 +07:00
Денис Кривоченко 642f2bba6a Fix instruct: replace <br> with \n for HTML line breaks 2026-07-21 17:43:31 +07:00
Денис Кривоченко 77cbb1d352 Fix instruct: use <br> for HTML line breaks, fix unknown cmd text 2026-07-21 17:32:53 +07:00
Денис Кривоченко 8e686b1ca3 Fix instruct handlers: correct newline escaping in text strings 2026-07-21 17:28:16 +07:00
Денис Кривоченко d30ceeda6b Add blank line between instruct header and buttons 2026-07-21 17:24:46 +07:00
Денис Кривоченко 0168a8b0a5 Fix instruct menu: remove extra blank line after header 2026-07-21 17:21:18 +07:00
Денис Кривоченко 260b6c3a8a Add instruct module with Trueconf and Email guides 2026-07-21 17:13:05 +07:00
21 changed files with 819 additions and 304 deletions
+109
View File
@@ -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)
+154
View File
@@ -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()
+5 -1
View File
@@ -85,6 +85,7 @@ 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 # Личный кабинет сотрудника (отпуска, расчетные листки, справки)
# ========================================================= # =========================================================
@@ -103,7 +104,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" # База данных с информацией по кадрам и ЗП
+59
View File
@@ -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")
View File
+123
View File
@@ -0,0 +1,123 @@
from utils.texts import (
UNKNOWN_MAIN_CMD_TEXT,
EMOJI_DIGITS,
INSTRUCT_MAIN_MENU_TEXT,
INSTRUCT_TRUECONF_TEXT,
INSTRUCT_TRUECONF_WITH_AD,
INSTRUCT_EMAIL_TEXT,
)
import logging
import asyncio
import os
from trueconf import Router, Message
from trueconf.types import FSInputFile
from utils.states import get_state, set_state, clear_state
# 🔌 Импортируем централизованную функцию сбора статистики из main
from utils.stats_logger import log_menu_stats
logger = logging.getLogger(__name__)
router = Router()
# Путь к файлу инструкции Trueconf
TRUECONF_INSTRUCT_PATH = os.path.join(os.path.dirname(__file__), "Проверка_наличия_и_авторизация_на_мобильном_устройстве_Trueconf.docx")
def get_user_cn(user_id: str) -> str | None:
"""
Получить CN пользователя из AD по его user_id (email или логин).
user_id из TrueConf — это обычно email, например 'ds.krivochenko@tcs.sibcem.ru'
Заменяет tcs.sibcem.ru на sibcem.ru для поиска в AD.
Ищет по всем OU из AD_BASES.
"""
from utils.ad_search import search_by_user_id
try:
# Заменяем tcs.sibcem.ru на sibcem.ru
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: user_id={user_id}, search_id={search_id}, short={short_username}")
entries = search_by_user_id(search_id, ["cn", "sAMAccountName", "mail", "userPrincipalName"])
if entries:
entry = entries[0]
cn = str(entry.cn)
mail = str(entry.mail) if 'mail' in entry else "N/A"
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 user_id # fallback — используем user_id как CN
@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")
set_state(user_id, "INSTRUCT_TRUECONF_VIEW")
cn = get_user_cn(user_id)
text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn)
await msg.answer(text, parse_mode="html")
if os.path.isfile(TRUECONF_INSTRUCT_PATH):
await msg.answer_document(FSInputFile(TRUECONF_INSTRUCT_PATH))
elif cmd == "2":
log_menu_stats(user_id, "Инструкции", "Почта")
set_state(user_id, "INSTRUCT_EMAIL_VIEW")
await msg.answer(INSTRUCT_EMAIL_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")
+10 -11
View File
@@ -129,19 +129,18 @@ async def handle_lk_error(msg: Message, user_id: str, action: str, error_details
# ИНТЕГРАЦИЯ С 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:
# Поиск CARD_ID по всем 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]
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)})))" if 'extensionAttribute2' in entry and entry.extensionAttribute2.value:
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: raise Exception(f"AD Card Query Error: {e}") except Exception as e:
raise Exception("AD Card Query Error: {}".format(e))
# ========================================================= # =========================================================
# МОДНОЕ УНИВЕРСАЛЬНОЕ ЯДРО ПАГИНАЦИИ СПИСКОВ ДЛЯ ЧАТА # МОДНОЕ УНИВЕРСАЛЬНОЕ ЯДРО ПАГИНАЦИИ СПИСКОВ ДЛЯ ЧАТА
+22 -13
View File
@@ -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")
+18 -71
View File
@@ -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 (Личный кабинет)")
@@ -120,68 +115,7 @@ async def main_menu(msg: Message):
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
@@ -208,6 +142,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 +171,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)
+15 -13
View File
@@ -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])]})
+16 -23
View File
@@ -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,54 +22,46 @@ 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)
if not entries:
# Ищем пользователя и просим вернуть атрибуты: имя (cn) и фото (thumbnailPhoto)
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}")
# Проверяем, есть ли вообще фото # Проверяем, есть ли вообще фото
if 'thumbnailPhoto' not in user or not user.thumbnailPhoto.value: if 'thumbnailPhoto' not in user or not user.thumbnailPhoto.value:
print("⚠️ У этого пользователя НЕТ фотографии в Active Directory.") print("⚠️ У этого пользователя НЕТ фотографии в Active Directory.")
return return
# Достаем байты фотографии # Достаем байты фотографии
photo_bytes = user.thumbnailPhoto.value photo_bytes = user.thumbnailPhoto.value
size_kb = len(photo_bytes) / 1024 size_kb = len(photo_bytes) / 1024
# Читаем картинку прямо из памяти (без сохранения на диск) с помощью Pillow # Читаем картинку прямо из памяти (без сохранения на диск) с помощью Pillow
image = Image.open(io.BytesIO(photo_bytes)) image = Image.open(io.BytesIO(photo_bytes))
width, height = image.size width, height = image.size
print("\n📊 --- ИНФОРМАЦИЯ О ФОТО В AD ---") print("\n📊 --- ИНФОРМАЦИЯ О ФОТО В AD ---")
print(f"📐 Разрешение : {width}x{height} пикселей") print(f"📐 Разрешение : {width}x{height} пикселей")
print(f"⚖️ Вес : {size_kb:.1f} KB") print(f"⚖️ Вес : {size_kb:.1f} KB")
print(f"🗂 Формат : {image.format}") print(f"🗂 Формат : {image.format}")
print("----------------------------------\n") print("----------------------------------\n")
# Бонус: сохраняем фото во временную папку, чтобы вы могли на него взглянуть # Бонус: сохраняем фото во временную папку, чтобы вы могли на него взглянуть
safe_name = target_user.replace('@', '_').replace('.', '_') safe_name = target_user.replace('@', '_').replace('.', '_')
save_path = f"/tmp/{safe_name}_ad.jpg" save_path = f"/tmp/{safe_name}_ad.jpg"
with open(save_path, "wb") as f: with open(save_path, "wb") as f:
f.write(photo_bytes) f.write(photo_bytes)
print(f"💾 Оригинал из AD сохранен сюда: {save_path}") print(f"💾 Оригинал из AD сохранен сюда: {save_path}")
print("Вы можете скачать его через WinSCP или открыть в TrueConf, чтобы оценить качество глазами.") print("Вы можете скачать его через WinSCP или открыть в TrueConf, чтобы оценить качество глазами.")
except Exception as e: except Exception as e:
print(f"❌ Ошибка при подключении к AD: {e}") print(f"❌ Ошибка при подключении к AD: {e}")
+56 -150
View File
@@ -23,7 +23,7 @@ if not sd_workflow_logger.handlers:
handler.setFormatter(formatter) handler.setFormatter(formatter)
sd_workflow_logger.addHandler(handler) sd_workflow_logger.addHandler(handler)
# ---------------------------------- # ----------------------------------
# Абсолютный импорт конфигурации # Абсолютный импорт конфигурации
spec = importlib.util.spec_from_file_location("custom_config", 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", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "config.py"))
custom_config = importlib.util.module_from_spec(spec) custom_config = importlib.util.module_from_spec(spec)
@@ -44,14 +44,14 @@ from utils.texts import (
sd_uploading_file, sd_uploading_file,
sd_file_attached, sd_file_attached,
sd_file_upload_error, sd_file_upload_error,
) )
from utils.texts import UNKNOWN_MAIN_CMD_TEXT from utils.texts import UNKNOWN_MAIN_CMD_TEXT
# 🔌 Импортируем централизованную функцию сбора статистики из main # 🔌 Импортируем централизованную функцию сбора статистики из main
from utils.stats_logger import log_menu_stats from utils.stats_logger import log_menu_stats
# Таймаут ожидания вложений после текста (секунды) # Таймаут ожидания вложений после текста (секунды)
ATTACHMENT_WAIT_TIMEOUT = 2.0 ATTACHMENT_WAIT_TIMEOUT = 2.0
SD_DISPATCHER_SYSTEM_PROMPT = ( SD_DISPATCHER_SYSTEM_PROMPT = (
"Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — сформулировать краткую и понятную тему ИТ-заявки по-русски (от 3 до 7 слов) на основе текста пользователя.\n" "Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — сформулировать краткую и понятную тему ИТ-заявки по-русски (от 3 до 7 слов) на основе текста пользователя.\n"
"ПРАВИЛА:\n" "ПРАВИЛА:\n"
@@ -59,7 +59,7 @@ SD_DISPATCHER_SYSTEM_PROMPT = (
"2. Отвечать на английском языке ЗАПРЕЩЕНО.\n" "2. Отвечать на английском языке ЗАПРЕЩЕНО.\n"
"3. Тебе категорически запрещено решать проблему или писать мануалы по настройке.\n" "3. Тебе категорически запрещено решать проблему или писать мануалы по настройке.\n"
"4. Не пиши префиксы 'Тема:', 'Заголовок:' в ответе.\n" "4. Не пиши префиксы 'Тема:', 'Заголовок:' в ответе.\n"
"5. Если в тексте только приветствие или мат — выведи ровно одно слово: СПАМ.") "5. Если в тексте только приветствие или мат — выведи ровно одно слово: СПАМ.")
# Глушим системные предупреждения # Глушим системные предупреждения
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
@@ -67,71 +67,25 @@ 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, "mail": user.mail.value if 'mail' in user else None,
"mail": user.mail.value if 'mail' in user else None, "city": user.l.value if 'l' in user else "Кемерово",
"city": user.l.value if 'l' in user else "Кемерово", "is_disabled": bool(uac & 2)
"is_disabled": bool(uac & 2) }
} except Exception as e:
except Exception as e: logger.error(f"Ошибка LDAP: {e}")
logger.error(f"Ошибка LDAP: {e}") return None
return None
async def generate_smart_subject(text: str) -> str: async def generate_smart_subject(text: str) -> str:
import re, traceback, importlib.util, httpx import re, traceback, importlib.util, httpx
@@ -142,14 +96,14 @@ async def generate_smart_subject(text: str) -> str:
OLLAMA_SUBJECT_MODEL = custom_config.OLLAMA_SUBJECT_MODEL OLLAMA_SUBJECT_MODEL = custom_config.OLLAMA_SUBJECT_MODEL
def has_chinese(t): return bool(re.search(r'[\u4e00-\u9fff]', str(t))) def has_chinese(t): return bool(re.search(r'[\u4e00-\u9fff]', str(t)))
if not text or len(text.split()) < 2: return "Заявка из КЛЕВЕР" if not text or len(text.split()) < 2: return "Заявка из КЛЕВЕР"
user_msg = f"Сформулируй краткую тему для следующей ИТ-заявки:\n{text}" user_msg = f"Сформулируй краткую тему для следующей ИТ-заявки:\n{text}"
messages = [ messages = [
{"role": "system", "content": SD_DISPATCHER_SYSTEM_PROMPT}, {"role": "system", "content": SD_DISPATCHER_SYSTEM_PROMPT},
{"role": "user", "content": user_msg} {"role": "user", "content": user_msg}
] ]
try: try:
payload = {"model": OLLAMA_SUBJECT_MODEL, "messages": messages, "temperature": 0.0} payload = {"model": OLLAMA_SUBJECT_MODEL, "messages": messages, "temperature": 0.0}
sd_workflow_logger.info(f"📝 [Subject Gen] Payload: {payload}") sd_workflow_logger.info(f"📝 [Subject Gen] Payload: {payload}")
@@ -173,36 +127,18 @@ 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): async def _send_req(email, current_city):
req_payload = {"request": {"subject": subject, "description": html_desc, "requester": {"name": email}}} payload = {"request": {"subject": subject, "description": html_desc, "requester": {"email_id": email}, "udf_fields": {"udf_pick_301": current_city}}}
if template_id: async with httpx.AsyncClient(verify=False) as client:
req_payload["request"]["template"] = {"id": template_id} return await client.post(endpoint, headers=headers, data={"input_data": json.dumps(payload)}, timeout=15.0)
# Найти service_category по шаблону resp = await _send_req(requester_email, city)
for t in templates: data = resp.json() if resp.status_code in [200, 201] else {}
if str(t.get("id")) == str(template_id): if data.get("response_status", {}).get("status_code") != 2000:
sc = t.get("service_category", {}) resp = await _send_req(requester_email, "Кемерово")
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:
resp = await client.post(endpoint, headers=headers, params={"input_data": json.dumps(req_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}")
return resp
resp = await _send_req(requester_email, city, template_id, templates)
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") return data.get("request", {}).get("id")
@@ -241,7 +177,7 @@ async def process_and_upload_file(file_id: str, filename: str, ticket_id: str):
return False return False
finally: finally:
if downloaded_path and os.path.exists(downloaded_path): if downloaded_path and os.path.exists(downloaded_path):
os.remove(downloaded_path) os.remove(downloaded_path)
def _extract_text_from_content(content): def _extract_text_from_content(content):
"""Извлечение текста из msg.content.""" """Извлечение текста из msg.content."""
@@ -283,8 +219,6 @@ 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,7 +227,7 @@ 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:
@@ -338,10 +272,10 @@ async def _wait_for_attachments_and_create(user_id, session):
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
sd_workflow_logger.info(f"⏰ [SD] Timeout reached. Initiating ticket creation for user {user_id}") sd_workflow_logger.info(f"⏰ [SD] Timeout reached. Initiating ticket creation for user {user_id}")
msg = session.get("msg") msg = session.get("msg")
login = session.get("login") login = session.get("login")
@@ -354,23 +288,23 @@ async def sd_module_handler(msg: Message):
sd_workflow_logger.info(f"🚀 [SD Workflow Start] User: {user_id}") sd_workflow_logger.info(f"🚀 [SD Workflow Start] User: {user_id}")
if states.get_state(user_id) != "SD_MODE": if states.get_state(user_id) != "SD_MODE":
return return
msg_text = _extract_text_from_content(msg.content) msg_text = _extract_text_from_content(msg.content)
is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT" is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT"
inline_attachments = _extract_attachments_from_content(msg.content, is_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)}") 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 msg.handled = True
login = user_id.split("@")[0] if "@" in user_id else user_id login = user_id.split("@")[0] if "@" in user_id else user_id
cmd = msg_text.lower() cmd = msg_text.lower()
# 🔄 НОРМАЛИЗАЦИЯ КНОПОК ВК-ЭМОДЗИ # 🔄 НОРМАЛИЗАЦИЯ КНОПОК ВК-ЭМОДЗИ
for raw_num, emoji_num in EMOJI_DIGITS.items(): for raw_num, emoji_num in EMOJI_DIGITS.items():
if cmd == emoji_num: if cmd == emoji_num:
cmd = raw_num cmd = raw_num
break break
# --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню) --- # --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню) ---
if cmd in ["0", "9", "/start", "меню"]: if cmd in ["0", "9", "/start", "меню"]:
log_menu_stats(user_id, "Service Desk", "Выход в главное меню") log_menu_stats(user_id, "Service Desk", "Выход в главное меню")
@@ -378,64 +312,36 @@ async def sd_module_handler(msg: Message):
sd_sessions.pop(user_id, None) sd_sessions.pop(user_id, None)
await msg.answer(MENU_TEXT, parse_mode="html") await msg.answer(MENU_TEXT, parse_mode="html")
return return
if user_id not in sd_sessions: if user_id not in sd_sessions:
sd_sessions[user_id] = {"step": "need_text", "files_queue": [], "post_create_queue": [], "templates": [], "template_names": {}} sd_sessions[user_id] = {"step": "need_text", "files_queue": [], "post_create_queue": []}
session = sd_sessions[user_id] 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
# --- ШАГ 1: ОЖИДАНИЕ ТЕКСТА --- # --- ШАГ 1: ОЖИДАНИЕ ТЕКСТА ---
if session["step"] == "need_text": if session["step"] == "need_text":
# Пользователь только что вошёл в SD_MODE — показываем меню
if len(msg_text.strip()) == 0 and not inline_attachments:
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
return
if not msg_text.strip() and not inline_attachments: if not msg_text.strip() and not inline_attachments:
await msg.answer(SD_UNKNOWN_CMD_TEXT, parse_mode="html") await msg.answer(SD_UNKNOWN_CMD_TEXT, parse_mode="html")
return return
# Если только вложение без текста — сохраняем и ждем текст # Если только вложение без текста — сохраняем и ждем текст
if not msg_text.strip() and inline_attachments: if not msg_text.strip() and inline_attachments:
session["files_queue"].extend(inline_attachments) session["files_queue"].extend(inline_attachments)
sd_workflow_logger.info(f"📎 [SD] Saved {len(inline_attachments)} attachment(s), waiting for text") sd_workflow_logger.info(f"📎 [SD] Saved {len(inline_attachments)} attachment(s), waiting for text")
await msg.answer(SD_TEXT_REQUIRED, parse_mode="html") await msg.answer(SD_TEXT_REQUIRED, parse_mode="html")
return return
# Если пришел текст (с вложениями или без) # Если пришел текст (с вложениями или без)
if inline_attachments: if inline_attachments:
session["files_queue"].extend(inline_attachments) session["files_queue"].extend(inline_attachments)
sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue") sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue")
# Настраиваем параметры сессии для ожидания # Настраиваем параметры сессии для ожидания
session["step"] = "waiting_for_attachments" session["step"] = "waiting_for_attachments"
session["msg_text"] = msg_text session["msg_text"] = msg_text
session["msg"] = msg session["msg"] = msg
session["user_id"] = user_id session["user_id"] = user_id
session["login"] = login session["login"] = login
# Сразу выдаем ОДНО сообщение пользователю, чтобы он видел реакцию бота # Сразу выдаем ОДНО сообщение пользователю, чтобы он видел реакцию бота
await msg.answer(SD_CREATING, parse_mode="html") await msg.answer(SD_CREATING, parse_mode="html")
@@ -445,7 +351,7 @@ async def sd_module_handler(msg: Message):
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session)) session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
sd_workflow_logger.info(f"⏳ [SD] Timer started: waiting {ATTACHMENT_WAIT_TIMEOUT}s for potentially more attachments...") sd_workflow_logger.info(f"⏳ [SD] Timer started: waiting {ATTACHMENT_WAIT_TIMEOUT}s for potentially more attachments...")
return return
# --- ШАГ 2: РЕЖИМ ОЖИДАНИЯ ДОП. ВЛОЖЕНИЙ (Сброс таймаута) --- # --- ШАГ 2: РЕЖИМ ОЖИДАНИЯ ДОП. ВЛОЖЕНИЙ (Сброс таймаута) ---
if session["step"] == "waiting_for_attachments": if session["step"] == "waiting_for_attachments":
if inline_attachments: if inline_attachments:
@@ -454,14 +360,14 @@ async def sd_module_handler(msg: Message):
if msg_text.strip(): if msg_text.strip():
session["msg_text"] = session["msg_text"] + " " + msg_text.strip() session["msg_text"] = session["msg_text"] + " " + msg_text.strip()
sd_workflow_logger.info(f"📝 [SD] Additional text appended") sd_workflow_logger.info(f"📝 [SD] Additional text appended")
# Перезапускаем таймер (пользователь активен, сдвигаем окно создания вперед) # Перезапускаем таймер (пользователь активен, сдвигаем окно создания вперед)
if "timer_task" in session and not session["timer_task"].done(): if "timer_task" in session and not session["timer_task"].done():
session["timer_task"].cancel() session["timer_task"].cancel()
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session)) 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") sd_workflow_logger.info(f"🔄 [SD] Timer reset due to user activity. Waiting another {ATTACHMENT_WAIT_TIMEOUT}s")
return return
# --- ШАГ 3: ЗАЯВКА СОЗДАЕТСЯ (Перехват файлов из "черной дыры") --- # --- ШАГ 3: ЗАЯВКА СОЗДАЕТСЯ (Перехват файлов из "черной дыры") ---
if session["step"] == "creating_ticket": if session["step"] == "creating_ticket":
if inline_attachments: if inline_attachments:
@@ -470,7 +376,7 @@ async def sd_module_handler(msg: Message):
session["post_create_queue"].extend(inline_attachments) 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.") sd_workflow_logger.info(f"📥 [SD Hole-Fix] Captured {len(inline_attachments)} file(s) DURING ticket creation API call.")
return return
# --- ШАГ 4: ПРИЕМ ДОП. ФАЙЛОВ К УЖЕ СОЗДАННОЙ ЗАЯВКЕ --- # --- ШАГ 4: ПРИЕМ ДОП. ФАЙЛОВ К УЖЕ СОЗДАННОЙ ЗАЯВКЕ ---
if session["step"] == "ticket_created": if session["step"] == "ticket_created":
if inline_attachments: if inline_attachments:
@@ -486,4 +392,4 @@ async def sd_module_handler(msg: Message):
await msg.answer(sd_file_upload_error(), parse_mode="html") await msg.answer(sd_file_upload_error(), parse_mode="html")
elif msg_text.strip(): elif msg_text.strip():
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html") await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return return
+4 -5
View File
@@ -45,11 +45,10 @@ 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}")
return DEFAULT_REQUESTER return DEFAULT_REQUESTER
Binary file not shown.
+59
View File
@@ -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,
}
+73
View File
@@ -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 []
+7
View File
@@ -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
View File
@@ -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}")
+80 -6
View File
@@ -170,11 +170,6 @@ 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:
return (
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)
@@ -400,7 +395,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>"
@@ -494,6 +489,7 @@ LK_UNKNOWN_CMD_TEXT = (
f"<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>" f"<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
) )
def lk_error_handling(emojis: dict) -> str: def lk_error_handling(emojis: dict) -> str:
return _lk_error_handling(emojis) return _lk_error_handling(emojis)
@@ -574,3 +570,81 @@ def lk_nav_text(emojis: dict) -> str:
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_with_ad(emojis: dict, cn: str) -> str:
"""
Текст Trueconf с проверкой группы 2FA.
cn — CN пользователя из AD.
"""
from utils.ad_checker import check_group_membership
result = check_group_membership(cn, "2FA")
if not result["found"]:
return (
f"🖥 <b>Инструкция по Trueconf</b>\n\n"
f"Пользователь {cn} не найден в Active Directory.\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
if result["in_group"]:
body = (
f"🖥 <b>Инструкция по Trueconf</b>\n\n"
f"✅ <b>Доступ к Trueconf разрешён.</b>\n\n"
f"У вас есть доступ к функциям Trueconf.\n\n"
f"Детали:\n"
f"• CN: {result['cn']}\n"
f"• Группы 2FA: {', '.join(result['matching_groups'])}\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
else:
body = (
f"🖥 <b>Инструкция по Trueconf</b>\n\n"
f"❌ <b>Доступ к Trueconf запрещён.</b>\n\n"
f"У вас нет группы 2FA в Active Directory.\n\n"
f"Детали:\n"
f"• CN: {result['cn']}\n"
f"• Групп 2FA: 0\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)