24 Commits

Author SHA1 Message Date
Денис Кривоченко 83e7963391 Clean: remove __pycache__ and search_bot files from service_desk branch 2026-07-20 15:06:45 +07:00
Денис Кривоченко e7dbe6fc25 Rebase: merge all SD commits into service_desk branch 2026-07-20 15:04:45 +07:00
Денис Кривоченко 5325b43c95 fix: add service_category from template to SD API request 2026-07-15 22:49:26 +07:00
Денис Кривоченко 50e9aa12e4 fix: use template instead of request_template for SD API 2026-07-15 22:40:55 +07:00
Денис Кривоченко 4df52d48f5 fix: use params instead of content for SD API requests 2026-07-15 22:28:15 +07:00
dddennnisss e2c0a2572d fix: добавить Content-Type: application/json и исправить input_data 2026-07-15 22:21:52 +07:00
dddennnisss d9f5ec16e1 fix: double-encode input_data для API Service Desk 2026-07-15 17:01:08 +07:00
dddennnisss eb35fcec82 fix: content= вместо data= для raw JSON body 2026-07-15 16:51:28 +07:00
dddennnisss d4dde34d90 fix: raw JSON body для API Service Desk 2026-07-15 16:40:58 +07:00
dddennnisss f09b693218 fix: добавить логирование ответа API для отладки 2026-07-15 16:16:14 +07:00
dddennnisss 88fca32156 fix: исправить API call (json вместо data) и добавить _sd_ticket_create_error 2026-07-15 16:08:56 +07:00
dddennnisss c4827ce559 feat: встроить шаблоны в SD_MAIN_MENU_TEXT, после выбора показать "Опишите проблему текстом" 2026-07-15 15:54:00 +07:00
dddennnisss 1a4f21cc20 fix: добавить sd_workflow_logger и относительный путь к config 2026-07-15 14:48:15 +07:00
dddennnisss 066f057003 feat: шаблоны показываются в main.py при нажатии 3, sd_module_handler только обрабатывает выбор 2026-07-15 14:37:14 +07:00
dddennnisss 067c8846b0 fix: добавить msg.handled=True чтобы sd_module_handler не перезаписал 2026-07-15 14:19:20 +07:00
dddennnisss 666435be3e feat: показать шаблоны вместо SD_MAIN_MENU_TEXT при нажатии 3 2026-07-15 13:58:24 +07:00
dddennnisss 914c964e08 fix: импортировать и инициализировать sd_sessions в main.py 2026-07-15 13:06:36 +07:00
dddennnisss 8267466ee6 fix: восстановить выбор шаблона после SD_MAIN_MENU_TEXT 2026-07-15 13:03:59 +07:00
dddennnisss b0439bd725 feat: встроить шаблоны в SD_MAIN_MENU_TEXT при нажатии 3 2026-07-15 13:02:38 +07:00
dddennnisss 39a2700555 fix: убрать SD_MAIN_MENU_TEXT из главного меню, показывать шаблоны 2026-07-15 12:52:30 +07:00
dddennnisss 691c112ace fix: инициализация шаблонов до need_text 2026-07-15 12:42:31 +07:00
dddennnisss f7aed57df4 fix: обновить тексты выбора шаблона 2026-07-15 12:38:30 +07:00
dddennnisss e07b546f08 feat: пагинация шаблонов Service Desk по 10 на страницу 2026-07-15 12:33:56 +07:00
dddennnisss efda2c8ae9 feat: добавить выбор шаблона Service Desk перед созданием заявки 2026-07-15 12:24:23 +07:00
31 changed files with 508 additions and 2202 deletions
-109
View File
@@ -1,109 +0,0 @@
#!/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
@@ -1,154 +0,0 @@
#!/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.
+2 -14
View File
@@ -85,7 +85,6 @@ 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 # Личный кабинет сотрудника (отпуска, расчетные листки, справки)
# ========================================================= # =========================================================
@@ -95,9 +94,7 @@ 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"
] ]
# ========================================================= # =========================================================
@@ -106,10 +103,7 @@ 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_BASES = ( # Базовые пути поиска пользователей в AD (несколько OU) AD_BASE = "OU=-Пользователи,DC=sibcem,DC=ru" # Базовый путь поиска пользователей в AD
"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" # База данных с информацией по кадрам и ЗП
@@ -223,9 +217,3 @@ 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
-59
View File
@@ -1,59 +0,0 @@
#!/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
-557
View File
@@ -1,557 +0,0 @@
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")
+37 -100
View File
@@ -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 = 0.5 USER_COOLDOWN_SEC = 1.5
EMAIL_COOLDOWN_SEC = 10.0 EMAIL_COOLDOWN_SEC = 10.0
user_last_request = {} user_last_request = {}
@@ -122,24 +122,26 @@ 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 = system_error_text(EMOJI_DIGITS) error_msg = lk_error_handling(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:
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru") server = Server(config.AD_SERVER, get_info=ALL)
entries = search_by_user_id(search_id, attributes=['extensionAttribute2']) conn = Connection(server, user=config.AD_USER, password=config.AD_PASSWORD, auto_bind=True)
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)})))"
if entries and 'extensionAttribute2' in entries[0] and entries[0].extensionAttribute2.value: conn.search(config.AD_BASE, search_filter, attributes=['extensionAttribute2'])
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: except Exception as e: raise Exception(f"AD Card Query Error: {e}")
raise Exception(f"AD Card Query Error: {e}")
# ========================================================= # =========================================================
# МОДНОЕ УНИВЕРСАЛЬНОЕ ЯДРО ПАГИНАЦИИ СПИСКОВ ДЛЯ ЧАТА # МОДНОЕ УНИВЕРСАЛЬНОЕ ЯДРО ПАГИНАЦИИ СПИСКОВ ДЛЯ ЧАТА
@@ -198,64 +200,18 @@ 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):
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id) now = datetime(2026, 6, 3)
months_ru = { prev_1 = now.replace(day=1) - timedelta(days=1)
1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь", prev_2 = prev_1.replace(day=1) - timedelta(days=1)
7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь" months_ru = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь", 7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"}
}
now = datetime.now() menu_text = lk_tabel_months_menu_text(prev_1, prev_2, months_ru, EMOJI_DIGITS)
# Генерируем список из 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")
@@ -311,22 +267,13 @@ 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()
current_state = get_state(user_id) if current_time - user_last_request.get(user_id, 0) < USER_COOLDOWN_SEC: return
# Если пользователь находится внутри меню ЛК, помечаем сообщение обработанным,
# чтобы оно не улетало в 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: if not msg.text: return
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():
@@ -425,6 +372,7 @@ 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":
@@ -436,31 +384,17 @@ 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.isdigit(): elif cmd in ["1", "2"]:
session = user_page_sessions.get(user_id) log_menu_stats(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)
tabel_report = await asyncio.to_thread( now = datetime(2026, 6, 3)
get_tabel_report_text, 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)
card_id, tabel_report = await asyncio.to_thread(get_tabel_report_text, card_id, target_date.year, target_date.month)
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: except Exception as e: await handle_lk_error(msg, user_id, "Формирование табеля", str(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")
@@ -506,8 +440,7 @@ async def lk_router_handler(msg: Message):
return return
if not session: if not session:
logger.warning(f"Сессия авторизации не найдена для пользователя {user_id}") await msg.answer("⚠️ <i>Сессия авторизации не найдена. Начните сначала.</i>", parse_mode="html")
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
@@ -621,8 +554,7 @@ async def lk_router_handler(msg: Message):
else: else:
raise Exception(f"Внутренний каскадный шлюз недоступен: {channel_info}") raise Exception(f"Внутренний каскадный шлюз недоступен: {channel_info}")
else: else:
logger.warning(f"Неверный индекс месяца для пользователя {user_id}: cmd={cmd}") await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
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")
@@ -646,6 +578,7 @@ 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":
@@ -673,6 +606,7 @@ 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":
@@ -685,12 +619,14 @@ 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":
@@ -734,6 +670,7 @@ 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":
@@ -741,5 +678,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")
-245
View File
@@ -1,245 +0,0 @@
#!/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()
+13 -22
View File
@@ -13,10 +13,7 @@ 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_BASES = ( AD_BASE = "OU=-Пользователи,DC=sibcem,DC=ru"
"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"
@@ -35,6 +32,7 @@ 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,
@@ -43,29 +41,22 @@ 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 = (
"(&(objectClass=user)" f"(&(objectClass=user)"
"(|(mail={})" f"(|(mail={safe_user_id})"
"(userPrincipalName={})" f"(userPrincipalName={safe_user_id})"
"(userPrincipalName={}))" f"(userPrincipalName={escape_filter_chars(f'{short_username}@sibcem.ru')})"
"(sAMAccountName={}))".format( f"(sAMAccountName={escape_filter_chars(short_username)})))"
safe_user_id,
safe_user_id,
escape_filter_chars(short_username) + "@sibcem.ru",
escape_filter_chars(short_username),
)
) )
for base in AD_BASES: conn.search(
conn.search( AD_BASE,
base, search_filter,
search_filter, attributes=['mobile', 'telephoneNumber']
attributes=['mobile', 'telephoneNumber'] )
)
if conn.entries:
break
if not conn.entries: if not conn.entries:
raise Exception("Пользователь не найден в AD") raise Exception("Пользователь не найден в AD")
+71 -18
View File
@@ -1,6 +1,7 @@
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
@@ -17,6 +18,13 @@ 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__)))
@@ -71,7 +79,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 from service_desk.handlers import router as sd_router, SD_MAIN_MENU_TEXT, sd_sessions
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
@@ -80,9 +88,6 @@ 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,7 +120,68 @@ 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", "Вход")
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html") # Помечаем сообщение как обработанное, чтобы sd_module_handler не перезаписал
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
@@ -142,18 +208,6 @@ 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
@@ -171,7 +225,6 @@ 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)
+13 -15
View File
@@ -5,39 +5,37 @@ 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 from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASE
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 = "(mail={})".format(target_user) ldap_filter = f"(mail={target_user})"
else: else:
ldap_filter = "(sAMAccountName={})".format(target_user) ldap_filter = f"(sAMAccountName={target_user})"
entries = search_by_filter(ldap_filter, ["cn"]) # Подключаемся к AD
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])]})
+23 -16
View File
@@ -7,8 +7,7 @@ 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 from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASE
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")
@@ -22,46 +21,54 @@ def check_user_photo(target_user: str):
ldap_filter = f"(sAMAccountName={target_user})" ldap_filter = f"(sAMAccountName={target_user})"
try: try:
# Подключаемся к AD и ищем по всем OU из AD_BASES # Подключаемся к AD
entries = ad_search_all_bases(ldap_filter, ["cn", "thumbnailPhoto"]) server = Server(AD_SERVER, get_info=ALL)
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 = entries[0] user = conn.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}")
+2 -2
View File
@@ -19,7 +19,7 @@ from photo_bot.photo_processor import prepare_ad_photo
from utils.texts import ( from utils.texts import (
EMOJI_DIGITS, EMOJI_DIGITS,
PHOTO_MAIN_MENU_TEXT, PHOTO_MAIN_MENU_TEXT,
system_error_text, PHOTO_UNKNOWN_CMD_TEXT,
photo_error_handling, photo_error_handling,
PHOTO_UPLOADING_TEXT, PHOTO_UPLOADING_TEXT,
photo_success_sent, photo_success_sent,
@@ -188,7 +188,7 @@ async def photo_module_handler(msg: Message):
# --- ИСПРАВЛЕНО: Защита от мусорного ввода при висящем превью --- # --- ИСПРАВЛЕНО: Защита от мусорного ввода при висящем превью ---
else: else:
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html") await msg.answer(PHOTO_UNKNOWN_CMD_TEXT, parse_mode="html")
return return
# --- ПРИЕМ НОВОГО ФОТО --- # --- ПРИЕМ НОВОГО ФОТО ---
+239 -186
View File
@@ -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
# Таймаут ожидания вложений после текста (секунды) # Таймаут ожидания вложений после текста (секунды)
@@ -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,25 +67,71 @@ 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:
from utils.ad_search import search_by_login server = Server(AD_SERVER, get_info=ALL)
entries = search_by_login(login, ["displayName", "mail", "l", "userAccountControl"]) conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
if entries: conn.search(search_base=AD_BASE, search_filter=f"(sAMAccountName={login})", attributes=["displayName", "mail", "l", "userAccountControl"])
user = entries[0] if conn.entries:
uac = user.userAccountControl.value if 'userAccountControl' in user else 0 user = conn.entries[0]
return { uac = user.userAccountControl.value if 'userAccountControl' in user else 0
"name": user.displayName.value if 'displayName' in user else login, return {
"mail": user.mail.value if 'mail' in user else None, "name": user.displayName.value if 'displayName' in user else login,
"city": user.l.value if 'l' in user else "Кемерово", "mail": user.mail.value if 'mail' in user else None,
"is_disabled": bool(uac & 2) "city": user.l.value if 'l' in user else "Кемерово",
} "is_disabled": bool(uac & 2)
except Exception as e: }
logger.error(f"Ошибка LDAP: {e}") except Exception as e:
return None logger.error(f"Ошибка LDAP: {e}")
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
@@ -127,31 +173,39 @@ 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): async def create_ticket_in_sd(requester_email: str, subject: str, description: str, city: str, template_id: str | None = None, templates: list | None = None):
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"} headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json", "Content-Type": "application/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 по шаблону
for t in templates:
# Безопасная обработка сбоев сети / отсуствия доступа к серверу SD if str(t.get("id")) == str(template_id):
try: sc = t.get("service_category", {})
resp = await _send_req(requester_email, city) 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:
resp = await _send_req(requester_email, "Кемерово") return data.get("request", {}).get("id")
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):
@@ -187,7 +241,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."""
@@ -229,8 +283,9 @@ 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")
try: sd_workflow_logger.info(f"[Create] Template ID: {template_id}")
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}")
sender_email = ad_user.get("mail") if ad_user else DEFAULT_REQUESTER sender_email = ad_user.get("mail") if ad_user else DEFAULT_REQUESTER
@@ -238,13 +293,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) ticket_id = await create_ticket_in_sd(sender_email, subject, msg_text, city, template_id, session.get("templates", []))
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}")
@@ -252,7 +307,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}")
@@ -270,20 +325,19 @@ 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(system_error_text(EMOJI_DIGITS), parse_mode="html") await msg.answer(sd_ticket_create_error(), parse_mode="html")
except Exception as e: except Exception as e:
logger.exception(f"❌ [SD Ticket Error] {user_id}: {e}") logger.exception(f"Ошибка: {e}")
sd_workflow_logger.error(f"❌ [SD Ticket Error] {user_id}: {e}") sd_workflow_logger.error(f"❌ [SD Error] {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
@@ -292,145 +346,144 @@ 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:
try: await _create_ticket_and_attach_files(user_id, session["msg_text"], session, msg, login)
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):
try: user_id = msg.from_user.id
user_id = msg.from_user.id 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
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
# --- ШАГ 1: ОЖИДАНИЕ ТЕКСТА ---
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 return
if not msg_text.strip() and not inline_attachments:
await msg.answer(SD_UNKNOWN_CMD_TEXT, parse_mode="html")
return
msg_text = _extract_text_from_content(msg.content) # Если только вложение без текста — сохраняем и ждем текст
is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT" if not msg_text.strip() and inline_attachments:
inline_attachments = _extract_attachments_from_content(msg.content, is_attachment) 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
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)}") # Если пришел текст (с вложениями или без)
if inline_attachments:
session["files_queue"].extend(inline_attachments)
sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue")
msg.handled = True # Настраиваем параметры сессии для ожидания
login = user_id.split("@")[0] if "@" in user_id else user_id session["step"] = "waiting_for_attachments"
cmd = msg_text.lower() session["msg_text"] = msg_text
session["msg"] = msg
session["user_id"] = user_id
session["login"] = login
# 🔄 НОРМАЛИЗАЦИЯ КНОПОК ВК-ЭМОДЗИ # Сразу выдаем ОДНО сообщение пользователю, чтобы он видел реакцию бота
for raw_num, emoji_num in EMOJI_DIGITS.items(): await msg.answer(SD_CREATING, parse_mode="html")
if cmd == emoji_num:
cmd = raw_num # Запускаем фоновый таймер с механизмом сброса (Debounce)
break 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 started: waiting {ATTACHMENT_WAIT_TIMEOUT}s for potentially more attachments...")
return
# --- ВЫХОД --- # --- ШАГ 2: РЕЖИМ ОЖИДАНИЯ ДОП. ВЛОЖЕНИЙ (Сброс таймаута) ---
if cmd in ["0", "9", "/start", "меню"]: if session["step"] == "waiting_for_attachments":
log_menu_stats(user_id, "Service Desk", "Выход в главное меню") if inline_attachments:
states.clear_state(user_id) session["files_queue"].extend(inline_attachments)
sd_sessions.pop(user_id, None) sd_workflow_logger.info(f"📎 [SD] Received {len(inline_attachments)} more files. Total queue: {len(session['files_queue'])}")
await msg.answer(MENU_TEXT, parse_mode="html") if msg_text.strip():
return session["msg_text"] = session["msg_text"] + " " + msg_text.strip()
sd_workflow_logger.info(f"📝 [SD] Additional text appended")
if user_id not in sd_sessions: # Перезапускаем таймер (пользователь активен, сдвигаем окно создания вперед)
sd_sessions[user_id] = {"step": "need_text", "files_queue": [], "post_create_queue": []} if "timer_task" in session and not session["timer_task"].done():
session = sd_sessions[user_id] 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
# --- ШАГ 1: ОЖИДАНИЕ ТЕКСТА --- # --- ШАГ 3: ЗАЯВКА СОЗДАЕТСЯ (Перехват файлов из "черной дыры") ---
if session["step"] == "need_text": if session["step"] == "creating_ticket":
if not msg_text.strip() and not inline_attachments: if inline_attachments:
await msg.answer(SD_UNKNOWN_CMD_TEXT, parse_mode="html") if "post_create_queue" not in session:
return session["post_create_queue"] = []
session["post_create_queue"].extend(inline_attachments)
if not msg_text.strip() and inline_attachments: sd_workflow_logger.info(f"📥 [SD Hole-Fix] Captured {len(inline_attachments)} file(s) DURING ticket creation API call.")
session["files_queue"].extend(inline_attachments) return
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
# --- ШАГ 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))
session["timer_task"].add_done_callback(lambda t: _on_sd_timer_done(t, user_id))
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:
_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
# --- ШАГ 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
-297
View File
@@ -1,297 +0,0 @@
#!/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)
+9 -68
View File
@@ -16,64 +16,14 @@ from utils.texts import (
transcription_error_bad_extension, transcription_error_bad_extension,
TRANSCRIPTION_UPLOADING_TEXT, TRANSCRIPTION_UPLOADING_TEXT,
transcription_success_received, transcription_success_received,
system_error_text, transcription_error_api,
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()
@@ -95,13 +45,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:
from utils.ad_search import search_by_login server = Server(AD_SERVER, get_info=ALL)
entries = search_by_login(login, ["mail"]) conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
if entries and 'mail' in entries[0] and entries[0].mail.value: conn.search(search_base=AD_BASE, search_filter=f"(sAMAccountName={login})", attributes=["mail"])
return str(entries[0].mail.value) if conn.entries and 'mail' in conn.entries[0] and conn.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()
@@ -166,21 +116,12 @@ 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:
_transcription_log_and_notify_email(f"API транскрипции вернул статус {response.status_code}", str(response.text[:500])) await msg.answer(transcription_error_api(response.status_code), parse_mode="html")
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}")
_transcription_log_and_notify_email("Загрузка/отправка файла на транскрипцию", str(e)) await msg.answer(transcription_error_system(), parse_mode="html")
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.
-59
View File
@@ -1,59 +0,0 @@
# /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
@@ -1,73 +0,0 @@
# /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,13 +62,6 @@ 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>")
+11 -9
View File
@@ -63,16 +63,18 @@ 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:
entries = search_by_user_id(user_id, ["extensionAttribute2"]) server = Server(config.AD_SERVER, get_info=ALL)
if not entries: conn = Connection(server, user=config.AD_USER, password=config.AD_PASSWORD, auto_bind=True)
raise Exception("Пользователь не найден в AD.") safe_user_id = escape_filter_chars(user_id)
entry = entries[0] short_username = user_id.split('@')[0]
if 'extensionAttribute2' in entry and entry.extensionAttribute2.value:
return str(entry.extensionAttribute2.value).strip() 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 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}")
+47 -48
View File
@@ -1,48 +1,47 @@
# /opt/trueconf_bot/utils/states.py # /opt/trueconf_bot/utils/states.py
import time import time
import config.config as config import config.config as config
user_states = {} user_states = {}
tc_bot = None # Общая переменная для экземпляра бота tc_bot = None # Общая переменная для экземпляра бота
# --- Константы состояний --- # --- Константы состояний ---
PHOTO_MODE = "PHOTO_MODE" 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): """Фиксирует новое состояние пользователя и обновляет метку времени активности"""
"""Фиксирует новое состояние пользователя и обновляет метку времени активности""" user_states[user_id] = {
user_states[user_id] = { "state": state,
"state": state, "last_active": time.time()
"last_active": time.time() }
}
def get_state(user_id):
def get_state(user_id): """
""" Возвращает текущее состояние пользователя.
Возвращает текущее состояние пользователя. Если время неактивности превысило лимит из config.py, сессия сбрасывается.
Если время неактивности превысило лимит из config.py, сессия сбрасывается. """
""" if user_id in user_states:
if user_id in user_states: session = user_states[user_id]
session = user_states[user_id]
# Динамически забираем значение таймаута из конфига (дефолт — 1800 сек / 30 мин)
# Динамически забираем значение таймаута из конфига (дефолт — 1800 сек / 30 мин) session_timeout = getattr(config, 'SESSION_TIMEOUT', 1800)
session_timeout = getattr(config, 'SESSION_TIMEOUT', 1800)
# Проверяем, сколько секунд прошло с момента последнего сообщения
# Проверяем, сколько секунд прошло с момента последнего сообщения if time.time() - session["last_active"] > session_timeout:
if time.time() - session["last_active"] > session_timeout: # Время вышло! Аппаратно сносим сессию навигации
# Время вышло! Аппаратно сносим сессию навигации del user_states[user_id]
del user_states[user_id] return None
return None
# Если время не вышло, обновляем таймер (пользователь проявил активность)
# Если время не вышло, обновляем таймер (пользователь проявил активность) session["last_active"] = time.time()
session["last_active"] = time.time() return session["state"]
return session["state"]
return None
return None
def clear_state(user_id):
def clear_state(user_id): """Принудительно очищает состояние пользователя (выход в корень)"""
"""Принудительно очищает состояние пользователя (выход в корень)""" if user_id in user_states:
if user_id in user_states: del user_states[user_id]
del user_states[user_id]
+41 -144
View File
@@ -2,16 +2,18 @@
# Все текстовые сообщения бота, отправляемые пользователю # Все текстовые сообщения бота, отправляемые пользователю
# ========================================================= # =========================================================
# GLOBAL CONSTANTS & HELPERS # GLOBAL CONSTANTS
# ========================================================= # =========================================================
# --- 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⃣",
@@ -27,6 +29,7 @@ EMOJI_DIGITS = {
} }
# --- Footer Templates --- # --- Footer Templates ---
# Шаблоны подвалов сообщений для навигации
def _footer(emojis: dict) -> str: def _footer(emojis: dict) -> str:
"""Стандартный футер с кнопками Назад и Меню""" """Стандартный футер с кнопками Назад и Меню"""
@@ -36,17 +39,6 @@ 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
@@ -148,14 +140,16 @@ 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_create_error(emojis: dict) -> str: def sd_ticket_created(ticket_id: int, subject: str, description: str) -> str:
return f"❌ <b>Ошибка создания заявки.</b>{_footer_main_only(emojis)}" return _sd_ticket_created(ticket_id, subject, description, EMOJI_DIGITS)
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)}"
@@ -176,8 +170,10 @@ 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_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>\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)
@@ -217,6 +213,14 @@ def _photo_unknown(emojis: dict) -> str:
f"{emojis['0']}В главное меню" 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: def _photo_uploading() -> str:
return "⏳ Отправка..." return "⏳ Отправка..."
@@ -253,7 +257,7 @@ PHOTO_MAIN_MENU_TEXT = _photo_welcome(EMOJI_DIGITS)
PHOTO_UNKNOWN_CMD_TEXT = _photo_unknown(EMOJI_DIGITS) PHOTO_UNKNOWN_CMD_TEXT = _photo_unknown(EMOJI_DIGITS)
def photo_error_handling() -> str: def photo_error_handling() -> str:
return system_error_text(EMOJI_DIGITS, show_back=False) return _photo_error_handling(EMOJI_DIGITS)
PHOTO_UPLOADING_TEXT = _photo_uploading() PHOTO_UPLOADING_TEXT = _photo_uploading()
@@ -319,6 +323,14 @@ 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)
@@ -337,7 +349,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 system_error_text(emojis, show_back=False) return _search_unavailable(emojis)
# ========================================================= # =========================================================
@@ -345,6 +357,8 @@ def search_unavailable_action(emojis: dict) -> str:
# ========================================================= # =========================================================
# Модуль Личного Кабинета (финансовые документы, табель и др.) # Модуль Личного Кабинета (финансовые документы, табель и др.)
# --- Constants ---
DOC_TYPES = { DOC_TYPES = {
"1": "Справка о доходах физического лица (2-НДФЛ)", "1": "Справка о доходах физического лица (2-НДФЛ)",
"2": "Справка об удержаниях за ДМС", "2": "Справка об удержаниях за ДМС",
@@ -357,6 +371,12 @@ 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>"
@@ -380,7 +400,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>"
@@ -444,15 +464,6 @@ 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>"
@@ -484,7 +495,7 @@ LK_UNKNOWN_CMD_TEXT = (
) )
def lk_error_handling(emojis: dict) -> str: def lk_error_handling(emojis: dict) -> str:
return system_error_text(emojis, show_back=True) return _lk_error_handling(emojis)
def lk_auth_processing(emojis: dict) -> str: def lk_auth_processing(emojis: dict) -> str:
return _lk_auth_processing(emojis) return _lk_auth_processing(emojis)
@@ -562,118 +573,4 @@ 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)