89 Commits

Author SHA1 Message Date
Денис Кривоченко 941cd569d8 fix: lk_diagnostic.py - check q_firm for ALL periods 2026-08-11 12:56:52 +07:00
dddennnisss 7c2a3f1c4a Обновить lk/test/lk_diagnostic.py 2026-08-11 12:32:51 +07:00
dddennnisss a9a4926e3e revert 427f6ed000
revert fix: lk_diagnostic.py - separate short_name for sAMAccountName and ANR
2026-08-11 11:53:55 +07:00
Денис Кривоченко 427f6ed000 fix: lk_diagnostic.py - separate short_name for sAMAccountName and ANR 2026-08-11 11:52:13 +07:00
Денис Кривоченко cc9d3ca78a fix: lk_diagnostic.py - use universal LDAP filter with ANR 2026-08-11 11:49:06 +07:00
Денис Кривоченко f0d927bcb6 fix: lk_diagnostic.py - use AD + correct SQL columns 2026-08-11 11:30:16 +07:00
Денис Кривоченко d2e7dd6234 fix: lk_diagnostic.py - fix strip syntax error 2026-08-11 11:26:53 +07:00
Денис Кривоченко 9e34b0597b add: diagnostic script for lk payslip issues 2026-08-11 11:17:38 +07:00
Денис Кривоченко 4db994f6de fix: import config module for email alerts 2026-07-31 01:29:55 +07:00
Денис Кривоченко ed669b6aac fix: add missing MIMEMultipart import for email alerts 2026-07-31 01:25:21 +07:00
Денис Кривоченко cecec601e0 fix: add debug logging to email alert function 2026-07-31 01:16:29 +07:00
Денис Кривоченко 6c466dc0f6 fix: use correct config reference for email alerts 2026-07-31 01:12:28 +07:00
Денис Кривоченко 974ecbb36d fix: restore missing logger definition 2026-07-31 00:46:21 +07:00
Денис Кривоченко a189c361d2 fix: restore missing router = Router() declaration 2026-07-31 00:40:39 +07:00
Денис Кривоченко 613102d0c9 fix: add email alerts for transcription module errors 2026-07-31 00:37:09 +07:00
Денис Кривоченко 4a464911d6 fix: add email alerts for all SD module errors 2026-07-31 00:26:24 +07:00
Денис Кривоченко 2fe74b08d8 fix: add email alerts for missing LK session and header errors 2026-07-31 00:03:06 +07:00
Денис Кривоченко 5f1c023652 fix: use system_error_text for lk error handling 2026-07-30 23:42:37 +07:00
Денис Кривоченко 4fbca23c6b fix: use system_error_text for photo unknown command 2026-07-30 23:39:23 +07:00
dddennnisss edbb1bbc95 Обновить service_desk/handlers.py 2026-07-30 23:35:06 +07:00
Денис Кривоченко 0973b83ba0 fix: correct indentation in _create_ticket_and_attach_files 2026-07-30 23:24:31 +07:00
Денис Кривоченко 996cfc7ad3 fix: catch SD errors in background task and send universal error message 2026-07-30 23:24:04 +07:00
Денис Кривоченко 81714f4bc2 fix: move error handling to background task where msg.answer works 2026-07-30 23:22:44 +07:00
Денис Кривоченко 6366f2a503 fix: correct indentation for done_callback 2026-07-30 23:17:17 +07:00
Денис Кривоченко c7e0fd8cc6 fix: add done_callback to SD timer task to catch background errors 2026-07-30 23:16:36 +07:00
Денис Кривоченко d3f6206113 fix: wrap entire SD handler in try-except to prevent crashes when SD is unreachable 2026-07-30 23:13:03 +07:00
Денис Кривоченко 3d6c99ed24 fix: wrap entire SD handler in try-except to prevent crashes when SD is unreachable 2026-07-30 23:11:58 +07:00
Денис Кривоченко 03808a553a fix: wrap entire SD handler in try-except to prevent crashes when SD is unreachable 2026-07-30 23:11:13 +07:00
Денис Кривоченко 6f187591b5 fix: wrap entire SD handler in try-except to prevent crashes when SD is unreachable 2026-07-30 23:10:28 +07:00
Денис Кривоченко 6c43c4315e fix: use universal system_error_text for all SD errors 2026-07-30 23:01:41 +07:00
Денис Кривоченко 1e4770ea16 fix: use universal system_error_text instead of API/system errors in transcription 2026-07-30 22:57:22 +07:00
dddennnisss c5edcbdd06 Обновить instruct/handlers.py 2026-07-30 22:29:53 +07:00
Денис Кривоченко a3b9796fe8 fix: add исполнен/выполнен to CLOSED_STATUSES 2026-07-30 22:15:21 +07:00
Денис Кривоченко aaf0c66ed6 fix: set_state before email unavailable text 2026-07-30 22:03:15 +07:00
Денис Кривоченко 0fa11655d5 fix: set state before showing email unavailable text so 9 works 2026-07-30 21:57:48 +07:00
Денис Кривоченко 7523a4849e fix: reformat email history ask text 2026-07-30 21:43:00 +07:00
Денис Кривоченко bfb3a0f359 fix: escape quotes in email_not_had text 2026-07-30 21:24:48 +07:00
Денис Кривоченко 9f737d3282 fix: separate text for email access not had case 2026-07-30 21:24:21 +07:00
Денис Кривоченко e550e657fe feat: ask about previous mobile email access before showing instructions 2026-07-30 17:21:32 +07:00
dddennnisss 3e98471e21 Обновить utils/texts.py 2026-07-30 16:57:50 +07:00
Денис Кривоченко 042ef92e98 fix: add .docx extension to email instruction file names 2026-07-30 14:50:55 +07:00
Денис Кривоченко 6f0f40a29e fix: send email text then attach docs separately without captions 2026-07-30 14:48:54 +07:00
Денис Кривоченко 3555d378b2 fix: attach first email doc with caption, second without 2026-07-30 14:37:03 +07:00
Денис Кривоченко e1a772ba0c fix: send email text then attach docs without caption 2026-07-30 14:34:19 +07:00
Денис Кривоченко 597aa56651 fix: use send_media_group to send both email instructions in one group 2026-07-30 14:24:42 +07:00
Денис Кривоченко 5f604e52d8 fix: send email text once, then attach docs without caption 2026-07-30 14:22:46 +07:00
Денис Кривоченко 1d8b4ef3c1 feat: attach Gmail Android and Apple email instructions when user has mail 2026-07-30 14:19:48 +07:00
dddennnisss 17cf93eed9 Загрузить файлы в «instruct» 2026-07-30 14:16:12 +07:00
Денис Кривоченко 1ab303ca60 fix: set raw_mail to None when AD mail is empty [] instead of string 2026-07-30 14:12:11 +07:00
Денис Кривоченко 2c5b296eaf fix: check raw AD mail attribute, not UPN fallback, for email availability check 2026-07-30 14:09:16 +07:00
Денис Кривоченко 8d4dcb006e fix: remove stale email dev text from texts.py 2026-07-30 13:54:19 +07:00
Денис Кривоченко 90b4d554bb feat: email instruction checks AD mail attribute and shows available/unavailable message 2026-07-30 13:51:33 +07:00
Денис Кривоченко 1585206707 fix: attach TrueConf instruction docx when ticket already exists (no 2FA) 2026-07-30 12:37:54 +07:00
Денис Кривоченко 03c0d6f759 feat: restore debug logs in instruct/handlers.py 2026-07-30 12:07:42 +07:00
dddennnisss 9ccda24a55 Обновить config/config.py 2026-07-30 12:04:48 +07:00
dddennnisss 8bb0e27028 Обновить instruct/handlers.py 2026-07-30 12:03:33 +07:00
dddennnisss 8aea92b84f Обновить utils/texts.py 2026-07-30 12:02:13 +07:00
Денис Кривоченко dd14853362 fix: remove subject filter from API URL (Russian chars), fetch all tickets and filter in code 2026-07-29 01:28:45 +07:00
Денис Кривоченко c42854c3a5 fix: search existing tickets by subject without status filter, check status in code, get details for CN matching 2026-07-29 01:28:10 +07:00
Денис Кривоченко 6ad50dcd4d fix: search existing SD ticket by CN (name) instead of email, with 3-match strategy 2026-07-29 01:18:26 +07:00
Денис Кривоченко 84a03f79ee fix: use requester name (CN) instead of email for SD API - fallback to AI when name fails 2026-07-29 01:12:48 +07:00
Денис Кривоченко ddbcb11685 revert: return to original instruct SD API behavior 2026-07-29 01:03:30 +07:00
Денис Кривоченко 80b15c86c9 fix: convert UPN @sibcem.ru to @tcs.sibcem.ru for SD API requester 2026-07-29 00:55:26 +07:00
Денис Кривоченко 36dbae5a56 fix: use input_data form field for SD API in instruct 2026-07-29 00:52:07 +07:00
Денис Кривоченко 708684fdfe fix: use service_desk API format in instruct (authtoken, Accept header, json=payload, html description) 2026-07-29 00:48:02 +07:00
Денис Кривоченко baeac6b1cc fix: convert UPN @sibcem.ru to @tcs.sibcem.ru for SD API 2026-07-29 00:41:53 +07:00
Денис Кривоченко 09a2ccc6b4 fix: use UPN as mail fallback in instruct when mail is empty 2026-07-29 00:30:04 +07:00
Денис Кривоченко e07b0af6b4 fix: use standard error stub in instruct module 2026-07-29 00:26:43 +07:00
Денис Кривоченко be16c4fe17 config: add bot_tcs OU to AD_BASES for ServiceAccounts path 2026-07-28 00:13:41 +07:00
Денис Кривоченко cc71f00506 fix: add missing exports for INSTRUCT_TRUECONF_PENDING_TEXT and INSTRUCT_TRUECONF_NO_TICKET_TEXT 2026-07-27 23:57:15 +07:00
Денис Кривоченко 9a24f48259 fix: separate ticket check from creation in Trueconf flow 2026-07-27 23:43:15 +07:00
Денис Кривоченко 9b65ec1e7e feat: add Trueconf access request flow with intermediate prompt and existing ticket check 2026-07-27 23:42:14 +07:00
Денис Кривоченко 354c6b0a51 feat: check for existing Trueconf ticket before creating new one 2026-07-27 23:13:34 +07:00
Денис Кривоченко 1ac0035b4d fix: use DEFAULT_REQUESTER fallback for ticket requester email 2026-07-27 22:40:29 +07:00
Денис Кривоченко c987f14cf9 simplify: single ticket description for Trueconf access request 2026-07-27 22:33:52 +07:00
Денис Кривоченко f1709f16b6 fix: use real ticket_id in Trueconf message and remove doc attachment when no access 2026-07-27 22:16:08 +07:00
Денис Кривоченко 0cfef14722 fix: remove ticket #23651 from Trueconf access denied message 2026-07-27 22:04:37 +07:00
Денис Кривоченко d2fe4aa1dc fix: restore ticket #23651 in Trueconf message 2026-07-27 21:56:58 +07:00
Денис Кривоченко 0ff681b84f fix: update Trueconf access denied message 2026-07-27 21:45:02 +07:00
Денис Кривоченко 3f239267d2 lk: hide empty months from timesheet menu, show only months with data 2026-07-26 17:47:23 +07:00
Денис Кривоченко 94bd664be8 lk: add current month as option 1 for timesheet 2026-07-26 17:41:55 +07:00
Денис Кривоченко c6bc67a98d lk: add 3rd month option for timesheet view 2026-07-26 17:35:37 +07:00
Денис Кривоченко 005b728d7c Rename SD config vars to SD_INSTRUCT_GROUP_NAME / SD_INSTRUCT_TECHNICIAN_NAME, move to section 11 2026-07-26 16:59:10 +07:00
Денис Кривоченко 866654a132 Add SD group/technician config and auto-assign tickets to Техподдержка ХК / Кривоченко 2026-07-26 16:39:18 +07:00
Денис Кривоченко 3c160afe20 Update Trueconf access denied message 2026-07-26 16:34:30 +07:00
Денис Кривоченко 347a909e06 Обновить instruct/handlers.py — имя файла Инструкция.docx 2026-07-23 23:10:30 +07:00
Денис Кривоченко dff39f025c Merge branch 'main' of http://192.168.1.108:3000/dddennnisss/trueconf_bot 2026-07-23 23:05:43 +07:00
Денис Кривоченко 523ed89519 Обновить instruct/handlers.py — файл с текстом в одном сообщении 2026-07-23 23:05:34 +07:00
Денис Кривоченко 6a6d59394a Обновить handlers.py — универсальный текст заявки 2FA 2026-07-23 22:27:50 +07:00
15 changed files with 1099 additions and 360 deletions
Binary file not shown.
Binary file not shown.
+17 -7
View File
@@ -81,19 +81,23 @@ for var_name, card_name in CREDENTIALS_MAP.items():
# =========================================================
# 1. ГЛОБАЛЬНЫЕ ФЛАГИ МОДУЛЕЙ
# =========================================================
ENABLE_SERVICE_DESK = False # Модуль создания заявок в техподдержку
ENABLE_PHOTO_BOT = False # Модуль обработки фото/сканов
ENABLE_SERVICE_DESK = True # Модуль создания заявок в техподдержку
ENABLE_PHOTO_BOT = True # Модуль обработки фото/сканов
ENABLE_TRANSCRIPTION = True # Модуль расшифровки голосовых сообщений (Whisper)
ENABLE_SEARCH_BOT = False # Модуль интеллектуального поиска по регламентам (RAG)
ENABLE_INSTRUCT = False # Модуль инструкций (Trueconf, Почта)
ENABLE_SEARCH_BOT = True # Модуль интеллектуального поиска по регламентам (RAG)
ENABLE_INSTRUCT = True # Модуль инструкций (Trueconf, Почта)
ENABLE_LK = True # Личный кабинет сотрудника (отпуска, расчетные листки, справки)
# =========================================================
# 2. КОНТРОЛЬ ДОСТУПА
# =========================================================
ENABLE_WHITELIST = False # Включить режим закрытого бета-тестирования
ENABLE_WHITELIST = True # Включить режим закрытого бета-тестирования
ALLOWED_USERS = [ # Список TrueConf ID разрешенных пользователей
"ds.krivochenko@tcs.sibcem.ru",
"mm.norenberg@tcs.sibcem.ru",
"a.kirpichev@tcs.sibcem.ru",
"bot_test@tcs.sibcem.ru",
"v.s.sholohov@tcs.sibcem.ru"
]
# =========================================================
@@ -203,7 +207,7 @@ DEBUG_TEXT_LIMIT = 250
# =========================================================
# 9. ПОДСИСТЕМА МОНИТОРИНГА И СТАТУСА БОТА (HEARTBEAT)
# =========================================================
ADMIN_CHAT_ID = "54966d1f56c2fda8e263a70c3a65ff0fad27ef7d"
ADMIN_CHAT_ID = "744bad93e58c4c498e15468562c6a2da3b610e9c"
ENABLE_HEARTBEAT_MONITOR = True
HEARTBEAT_CHECK_INTERVAL_SEC = 10.0
HEARTBEAT_MAX_FAILURES = 3
@@ -219,3 +223,9 @@ OTP_MAX_ATTEMPTS = 3
OTP_BLOCK_DURATION_SEC = 1800
# Глобальный таймаут неактивности пользователя в любом подменю (1800 секунд = 30 минут)
SESSION_TIMEOUT = 1800
# =========================================================
# 11. МОДУЛЬ INSTRUCT: НАСТРОЙКИ SERVICE DESK: ГРУППА И ИСПОЛНИТЕЛЬ
# =========================================================
SD_INSTRUCT_GROUP_NAME = "Техподдержка ХК" # Название группы в ServiceDesk
SD_INSTRUCT_TECHNICIAN_NAME = "Кривоченко Денис Сергеевич" # ФИО техника в ServiceDesk
+368 -72
View File
@@ -4,6 +4,9 @@ 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
@@ -14,14 +17,23 @@ from utils.texts import (
INSTRUCT_MAIN_MENU_TEXT,
INSTRUCT_TRUECONF_TEXT,
INSTRUCT_TRUECONF_WITH_AD,
INSTRUCT_EMAIL_TEXT,
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
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")
@@ -31,23 +43,110 @@ except ImportError:
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
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 (email или логин).
user_id из TrueConf — это обычно email, например 'ds.krivochenko@tcs.sibcem.ru'
"""
"""Получить CN пользователя из AD по его user_id."""
from utils.ad_search import search_by_user_id
try:
@@ -74,38 +173,45 @@ def get_user_cn(user_id: str) -> str | None:
def get_ad_user_info(user_id: str) -> dict | None:
"""
Получить полную информацию о пользователе из AD.
Возвращает {cn, mail, city} или 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"])
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 ""
# mail может быть "[]" (пустой LDAP объект), "" или "N/A" — фильтруем
mail = raw_mail if raw_mail and raw_mail not in ("[]", "N/A", "") else None
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, "city": city}
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_email: str, subject: str, description: str, city: str) -> str | None:
"""
Автономная прямая отправка HTTP-запроса в Service Desk API v3.
"""
# Если запрашивает системный ИИ или не указан email — ищем по ИМЕНИ
if requester_email == DEFAULT_REQUESTER or requester_email == "ai@sibcem.ru":
requester_payload = {"name": "Искусственный Интеллект"}
else:
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": {
@@ -118,6 +224,12 @@ async def _send_sd_api_request(requester_email: str, subject: str, description:
}
}
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)}
@@ -129,47 +241,140 @@ async def _send_sd_api_request(requester_email: str, subject: str, description:
res_json = response.json()
ticket_id = res_json.get("request", {}).get("id")
return str(ticket_id) if ticket_id else None
else:
logger.error(f"Service Desk API Error ({response.status_code}): {response.text}")
return 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.
Полностью автономная функция.
"""
"""Создать заявку в Service Desk для запроса доступа к Trueconf."""
subject = "Запрос доступа к Trueconf"
# Пытаемся получить информацию из AD
clean_user_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
ad_info = get_ad_user_info(user_id)
logger.info(f"AD search result for SD ticket ({user_id}): {ad_info}")
requester_email = (ad_info.get("mail") if ad_info else None) or DEFAULT_REQUESTER
city = ad_info.get("city", "Кемерово") if ad_info else "Кемерово"
if ad_info and ad_info.get("mail"):
requester_email = ad_info["mail"]
city = ad_info.get("city", "Кемерово")
description = (
f"Пользователь {cn} ({user_id}) отсутствует группа 2FA."
)
else:
requester_email = DEFAULT_REQUESTER
city = "Кемерово"
logger.info(f"Using DEFAULT_REQUESTER ({requester_email}) for user {user_id} (cn={cn})")
# Защита от дублей
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
if ad_info and not ad_info.get("mail"):
description = (
f"Пользователь {cn} ({user_id}) отсутствует группа 2FA."
)
else:
description = (
f"Пользователь {cn} ({user_id}) отсутствует группа 2FA."
)
description = (
f"Пользователь {cn} ({clean_user_id}) запросил инструкцию по Trueconf, "
f"но не имеет группы 2FA. Требуется выдача доступа."
)
try:
logger.info(f"Calling SD API directly: requester={requester_email}, city={city}")
ticket_id = await _send_sd_api_request(requester_email, subject, description, city)
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} (requester={requester_email})")
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
@@ -216,35 +421,126 @@ async def instruct_router_handler(msg: Message):
await msg.answer(MENU_TEXT, parse_mode="html")
elif cmd == "1":
log_menu_stats(user_id, "Инструкции", "Trueconf")
set_state(user_id, "INSTRUCT_TRUECONF_VIEW")
# Получаем CN пользователя
cn = get_user_cn(user_id)
if cn is None:
cn = user_id # fallback
cn = user_id
# Проверяем наличие группы 2FA в AD перед созданием заявки
from utils.ad_checker import check_group_membership
ad_check = check_group_membership(cn, "2FA")
ticket_id = None
# Создаём заявку в SD ТОЛЬКО если у пользователя НЕТ группы 2FA (или он не найден)
if not ad_check.get("in_group", False):
ticket_id = await create_sd_ticket_for_trueconf(user_id, cn)
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:
logger.info(f"User {cn} already has 2FA group. SD ticket creation skipped.")
# Показываем текст с результатом
text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn, ticket_id)
await msg.answer(text, parse_mode="html")
# Отправляем документ-инструкцию
if os.path.isfile(TRUECONF_INSTRUCT_PATH):
await msg.answer_document(FSInputFile(TRUECONF_INSTRUCT_PATH))
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")
await msg.answer(INSTRUCT_EMAIL_TEXT, parse_mode="html")
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")
+65 -27
View File
@@ -122,7 +122,7 @@ 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):
await asyncio.to_thread(log_and_notify_error, user_id, action, error_details)
error_msg = lk_error_handling(EMOJI_DIGITS)
error_msg = system_error_text(EMOJI_DIGITS)
await msg.answer(error_msg, parse_mode="html")
# =========================================================
@@ -198,31 +198,64 @@ async def send_dynamic_lk_main_menu(msg: Message, user_id: str):
)
except Exception as e:
logger.error(f"Не удалось собрать динамический заголовок ЛК: {e}")
await handle_lk_error(msg, user_id, "Сборка заголовка ЛК", str(e))
header = "👤 <b>Личный кабинет</b>\n\n"
menu_text = lk_main_menu_text(header, EMOJI_DIGITS)
await msg.answer(menu_text, parse_mode="html")
async def send_tabel_months_menu(msg: Message, user_id: str):
now = datetime.now()
m1 = now.replace(day=1) - timedelta(days=1) # Прошлый месяц (M-1)
m2 = m1.replace(day=1) - timedelta(days=1) # Позапрошлый месяц (M-2)
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
months_ru = {
1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь",
7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"
}
m1_str = f"{months_ru[m1.month]} {m1.year} г."
m2_str = f"{months_ru[m2.month]} {m2.year} г."
now = datetime.now()
# Генерируем список из 4 месяцев: текущий, прошлый, позапрошлый, за два месяца
candidates = []
m = now
for _ in range(4):
candidates.append((m.year, m.month))
m = m.replace(day=1) - timedelta(days=1)
menu_text = (
"⏱ <b>Выберите месяц для просмотра табеля:</b>\n\n"
f"{EMOJI_DIGITS['1']}{m1_str}\n"
f"{EMOJI_DIGITS['2']}{m2_str}\n\n"
f"<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
)
# Проверяем какие месяцы имеют данные в БД
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")
await msg.answer(menu_text, parse_mode="html")
@@ -403,24 +436,26 @@ async def lk_router_handler(msg: Message):
elif cmd == "9":
set_state(user_id, "LK_MODE")
await send_dynamic_lk_main_menu(msg, user_id)
elif cmd in ["1", "2"]:
log_menu_stats(user_id, "Личный кабинет", f"Просмотр табеля (вариант {cmd})")
elif cmd.isdigit():
session = user_page_sessions.get(user_id)
if not session or not session.get("tabel_months"):
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return
idx = int(cmd) - 1
available = session["tabel_months"]
if idx < 0 or idx >= len(available):
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return
target_year, target_month = available[idx]
log_menu_stats(user_id, "Личный кабинет", f"Просмотр табеля ({target_month}.{target_year})")
await msg.answer(lk_tabel_loading(EMOJI_DIGITS), parse_mode="html")
try:
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
now = datetime.now()
m1 = now.replace(day=1) - timedelta(days=1)
if cmd == "1":
target_date = m1 # Прошлый месяц
elif cmd == "2":
target_date = m1.replace(day=1) - timedelta(days=1) # Позапрошлый месяц
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")
await msg.answer(tabel_report + nav_text, parse_mode="html")
@@ -471,7 +506,8 @@ async def lk_router_handler(msg: Message):
return
if not session:
await msg.answer("⚠️ <i>Сессия авторизации не найдена. Начните сначала.</i>", parse_mode="html")
logger.warning(f"Сессия авторизации не найдена для пользователя {user_id}")
await handle_lk_error(msg, user_id, "Сессия авторизации не найдена", f"user_id={user_id}")
set_state(user_id, "LK_MODE")
await send_dynamic_lk_main_menu(msg, user_id)
return
@@ -585,7 +621,8 @@ async def lk_router_handler(msg: Message):
else:
raise Exception(f"Внутренний каскадный шлюз недоступен: {channel_info}")
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
logger.warning(f"Неверный индекс месяца для пользователя {user_id}: cmd={cmd}")
await handle_lk_error(msg, user_id, "Неверный индекс месяца", f"user_id={user_id}, cmd={cmd}")
except Exception as e: await handle_lk_error(msg, user_id, "Инициализация 2FA расчетного листа", str(e))
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
@@ -705,3 +742,4 @@ async def lk_router_handler(msg: Message):
elif cmd == "9": set_state(user_id, "LK_DOCS_MENU"); await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html")
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Диагностика данных сотрудника для модуля ЛК (расчетnye listki)."""
import os
import sys
BASE_DIR = "/opt/trueconf_bot"
os.chdir(BASE_DIR)
# Загрузка .env
def load_env(filepath):
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)
value = value.strip(' "')
value = value.strip("'")
value = value.strip()
os.environ[key.strip()] = value
load_env(os.path.join(BASE_DIR, "config", ".env"))
# Загрузка passwork
for pw_path in ["/opt/passwork", os.path.join(BASE_DIR, "passwork")]:
if os.path.isdir(pw_path) and pw_path not in sys.path:
sys.path.insert(0, pw_path)
from passwork import get_passwork_secrets
card_name = os.getenv("PW_ID_SQL", "").strip()
passwork_pool = get_passwork_secrets(required_cards=[card_name])
card_data = passwork_pool[card_name]
DB_USER = card_data["login"]
DB_PASSWORD = card_data["password"]
SQL_SERVER = os.getenv("SQL_SERVER", "SRVKEM-MOBILEIN.sibcem.ru")
SQL_DB_NAME = os.getenv("SQL_DB_NAME", "BossCopy")
import pymssql
import ldap3
import calendar
# --- Подключение к AD ---
print("=" * 60)
print("Диагностика сотрудника: la.guseynova@sibcem.ru")
print("=" * 60)
email = "la.guseynova@sibcem.ru"
search_id = email.replace("@tcs.sibcem.ru", "@sibcem.ru")
short_name = search_id.split("@")[0]
print(f"\n--- 0. Поиск в AD ---")
print(f" Email: {email}")
print(f" Search ID: {search_id}")
print(f" Short (без домена): {short_name}")
AD_SERVER = "172.16.20.20"
AD_BASE = "DC=sibcem,DC=ru"
try:
conn = ldap3.Connection(
ldap3.Server(AD_SERVER, connect_timeout=5),
auto_bind=True,
read_only=True
)
search_filter = f"(|(mail={search_id}*)(sAMAccountName={short_name})(userPrincipalName={search_id}*)(anr={short_name}))"
print(f" LDAP-фильтр: {search_filter}")
conn.search(
search_base=AD_BASE,
search_filter=search_filter,
attributes=["cn", "sAMAccountName", "mail", "userPrincipalName", "extensionAttribute2", "mobile", "personalPager"]
)
if conn.entries:
entry = conn.entries[0]
cn = str(entry.cn)
sam = str(entry.sAMAccountName)
mail = str(entry.mail) if 'mail' in entry else ""
upn = str(entry.userPrincipalName) if 'userPrincipalName' in entry else ""
ext2 = str(entry.extensionAttribute2) if 'extensionAttribute2' in entry else ""
mobile = str(entry.mobile) if 'mobile' in entry else ""
pager = str(entry.personalPager) if 'personalPager' in entry else ""
print(f"\n НАЙДЕН:")
print(f" CN: {cn}")
print(f" sAMAccountName: {sam}")
print(f" mail: {mail}")
print(f" UPN: {upn}")
print(f" extensionAttribute2 (CARD_ID): {ext2}")
print(f" mobile: {mobile}")
print(f" pager: {pager}")
else:
print(" Не найден!")
conn.unbind()
sys.exit(0)
conn.unbind()
except Exception as e:
print(f" Ошибка подключения к AD: {e}")
sys.exit(0)
# --- Подключение к SQL ---
print(f"\n--- 1. Подключение к SQL ---")
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(f" Подключено к {SQL_SERVER}/{SQL_DB_NAME}")
except Exception as e:
print(f" Ошибка подключения к SQL: {e}")
sys.exit(0)
# --- Получаем CARD_ID ---
card_id = None
print(f"\n--- 2. Поиск CARD_ID в PR_CARD ---")
cursor.execute(
"SELECT c.ID FROM UOV_SELFSERVICE_PR_CARD c "
"JOIN UOV_SELFSERVICE_PR_EMP e ON e.CARD_ID = c.ID "
"WHERE e.EMAIL_ID LIKE %s OR e.USERNAME LIKE %s",
(f"%{short_name}%", f"%{short_name}%")
)
rows = cursor.fetchall()
if rows:
for r in rows:
print(f" CARD_ID (по username): {r[0]}")
card_id = rows[0][0]
else:
print(f" Не найден по username")
if not card_id:
cursor.execute(
"SELECT c.ID FROM UOV_SELFSERVICE_PR_CARD c "
"JOIN UOV_SELFSERVICE_PR_EMP e ON e.CARD_ID = c.ID "
"WHERE e.EMAIL_ID LIKE %s",
(f"%{search_id.split('@')[0]}%",)
)
rows = cursor.fetchall()
if rows:
for r in rows:
print(f" CARD_ID (по email): {r[0]}")
card_id = rows[0][0]
else:
print(f" CARD_ID не найден!")
conn_sql.close()
sys.exit(0)
print(f"\n Используем CARD_ID: {card_id}")
# --- PR_EMP ---
print(f"\n--- 3. PR_EMP (профиль сотрудника) ---")
cursor.execute(
"SELECT ID, FIRM_ID, EMAIL_ID, USERNAME FROM UOV_SELFSERVICE_PR_EMP WHERE CARD_ID = %s",
(card_id,)
)
emp_rows = cursor.fetchall()
if emp_rows:
for r in emp_rows:
print(f" EMP_ID: {r[0]}, FIRM_ID: {r[1]}, EMAIL: {r[2]}, USERNAME: {r[3]}")
else:
print(" ПУСТО - нет записей в PR_EMP")
# --- PR_TRANS ---
print(f"\n--- 4. PR_TRANS (кадровые назначения) ---")
if emp_rows:
emp_ids = [r[0] for r in emp_rows]
placeholders = ",".join(["%s"] * len(emp_ids))
cursor.execute(
f"SELECT EMP_ID, D_FROM, D_TO, TP_ABBR, SYSPAY_ID "
f"FROM UOV_SELFSERVICE_PR_TRANS "
f"WHERE EMP_ID IN ({placeholders}) "
f"ORDER BY D_FROM DESC",
emp_ids
)
trans_rows = cursor.fetchall()
if trans_rows:
print(f" Найдено записей: {len(trans_rows)}")
for r in trans_rows[:10]:
print(f" EMP_ID: {r[0]}, D_FROM: {r[1]}, D_TO: {r[2]}, TP: {r[3]}, SYSPAY: {r[4]}")
else:
print(" ПУСТО - нет записей в PR_TRANS")
else:
print(" Пропуск (нет PR_EMP)")
# --- SL_YTAX ---
print(f"\n--- 5. SL_YTAX (периоды с данными) ---")
cursor.execute(
"SELECT DISTINCT TYEAR, TMONTH, CMONTH FROM UOV_SELFSERVICE_SL_YTAX "
"WHERE CARD_ID = %s ORDER BY CMONTH DESC",
(card_id,)
)
tax_rows = cursor.fetchall()
if tax_rows:
print(f" Найдено периодов: {len(tax_rows)}")
for r in tax_rows:
print(f" {r[0]}-{r[1]:02d} (CMONTH={r[2]})")
else:
print(" ПУСТО - нет периодов в SL_YTAX")
# --- 6. Проверка q_firm для ВСЕХ периодов ---
print(f"\n--- 6. Проверка q_firm для КАЖДОГО периода ---")
if tax_rows:
for tax_row in tax_rows:
tyear = tax_row[0]
tmonth = tax_row[1]
cmonth = tax_row[2]
_, 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" {tyear}-{tmonth:02d} (CMONTH={cmonth}): ✅ FIRM_ID={firm_row[0]}, SNAME={firm_row[1]}")
else:
print(f" {tyear}-{tmonth:02d} (CMONTH={cmonth}): ❌ q_firm НЕ НАШЁЛ FIRM_ID!")
# Фоллбэк
q_firm_fb = """
select 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
"""
cursor.execute(q_firm_fb, (card_id,))
fb_row = cursor.fetchone()
if fb_row:
print(f" Фоллбэк: FIRM_ID={fb_row[0]}")
else:
print(f" Фоллбэк НЕ НАШЁЛ - критическая ошибка!")
else:
print(" Нет периодов для проверки")
# --- 7. Сводка ---
print(f"\n--- 7. Сводка ---")
cursor.execute(
"SELECT COUNT(DISTINCT CMONTH), MIN(CMONTH), MAX(CMONTH) FROM UOV_SELFSERVICE_SL_YTAX WHERE CARD_ID = %s",
(card_id,)
)
summary = cursor.fetchone()
if summary:
print(f" Периодов в SL_YTAX: {summary[0]}")
print(f" Диапазон: {summary[1]} - {summary[2]}")
print(f"\n{'=' * 60}")
print("Диагностика завершена")
print("=" * 60)
conn_sql.close()
+3 -2
View File
@@ -19,7 +19,7 @@ from photo_bot.photo_processor import prepare_ad_photo
from utils.texts import (
EMOJI_DIGITS,
PHOTO_MAIN_MENU_TEXT,
PHOTO_UNKNOWN_CMD_TEXT,
system_error_text,
photo_error_handling,
PHOTO_UPLOADING_TEXT,
photo_success_sent,
@@ -188,7 +188,7 @@ async def photo_module_handler(msg: Message):
# --- ИСПРАВЛЕНО: Защита от мусорного ввода при висящем превью ---
else:
await msg.answer(PHOTO_UNKNOWN_CMD_TEXT, parse_mode="html")
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
return
# --- ПРИЕМ НОВОГО ФОТО ---
@@ -240,3 +240,4 @@ async def photo_module_handler(msg: Message):
except Exception as cleanup_err:
await asyncio.to_thread(log_and_notify_photo_error, user_id, "Очистка временных файлов", str(cleanup_err))
return
+155 -114
View File
@@ -44,9 +44,9 @@ from utils.texts import (
sd_uploading_file,
sd_file_attached,
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
# Таймаут ожидания вложений после текста (секунды)
@@ -131,17 +131,27 @@ async def create_ticket_in_sd(requester_email: str, subject: str, description: s
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"}
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>"
async def _send_req(email, current_city):
payload = {"request": {"subject": subject, "description": html_desc, "requester": {"email_id": email}, "udf_fields": {"udf_pick_301": current_city}}}
async with httpx.AsyncClient(verify=False) as client:
return await client.post(endpoint, headers=headers, data={"input_data": json.dumps(payload)}, timeout=15.0)
resp = await _send_req(requester_email, city)
data = resp.json() if resp.status_code in [200, 201] else {}
if data.get("response_status", {}).get("status_code") != 2000:
resp = await _send_req(requester_email, "Кемерово")
# Безопасная обработка сбоев сети / отсуствия доступа к серверу SD
try:
resp = await _send_req(requester_email, city)
data = resp.json() if resp.status_code in [200, 201] else {}
if data.get("response_status", {}).get("status_code") == 2000:
return data.get("request", {}).get("id")
if data.get("response_status", {}).get("status_code") != 2000:
resp = await _send_req(requester_email, "Кемерово")
data = resp.json() if resp.status_code in [200, 201] else {}
if data.get("response_status", {}).get("status_code") == 2000:
return data.get("request", {}).get("id")
except (httpx.RequestError, httpx.HTTPStatusError, Exception) as e:
logger.error(f"❌ [SD Network Error] Не удалось подключиться к ServiceDesk: {e}")
sd_workflow_logger.error(f"❌ [SD Network Error] {e}")
_sd_log_and_notify_email("Подключение к ServiceDesk", str(e))
return None
return None
async def process_and_upload_file(file_id: str, filename: str, ticket_id: str):
@@ -219,6 +229,7 @@ def _extract_attachments_from_content(content, is_attachment_type=False):
async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login):
"""Логика генерации темы, отправки в SD и загрузки всех очередей вложений."""
session["step"] = "creating_ticket"
try:
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}")
@@ -233,7 +244,7 @@ async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login
if ticket_id:
session["ticket_id"] = ticket_id
# 1. Загрузка основных вложений (накопленных до и во время таймаута)
# 1. Загрузка основных вложений
queued_files = list(session.get("files_queue", []))
if queued_files:
sd_workflow_logger.info(f"📎 [Upload] Uploading {len(queued_files)} file(s) to ticket #{ticket_id}")
@@ -241,7 +252,7 @@ async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login
await process_and_upload_file(f['file_id'], f['file_name'], ticket_id)
sd_workflow_logger.info(f"✅ [Upload] All queued file(s) uploaded")
# 2. Загрузка вложений, прилетевших во время выполнения API-запросов (из "черной дыры")
# 2. Загрузка вложений, прилетевших во время выполнения API-запросов
post_files = list(session.get("post_create_queue", []))
if post_files:
sd_workflow_logger.info(f"📎 [Upload Extra] Uploading {len(post_files)} late file(s) to ticket #{ticket_id}")
@@ -259,19 +270,20 @@ async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login
log_menu_stats(user_id, "Service Desk", f"Создание заявки #{ticket_id}")
else:
session["step"] = "need_text"
await msg.answer(sd_ticket_create_error(), parse_mode="html")
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
except Exception as e:
logger.exception(f"Ошибка: {e}")
sd_workflow_logger.error(f"❌ [SD Error] {e}")
logger.exception(f"❌ [SD Ticket Error] {user_id}: {e}")
sd_workflow_logger.error(f"❌ [SD Ticket Error] {user_id}: {e}")
_sd_log_and_notify_email(f"Создание заявки для {user_id}", str(e))
session["step"] = "need_text"
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):
"""Фоновый таймер с возможностью отмены (Debounce)."""
try:
await asyncio.sleep(ATTACHMENT_WAIT_TIMEOUT)
except asyncio.CancelledError:
return # Таймер сброшен, прерываем выполнение текущей таски
return
if session.get("step") != "waiting_for_attachments":
return
@@ -280,116 +292,145 @@ async def _wait_for_attachments_and_create(user_id, session):
msg = session.get("msg")
login = session.get("login")
if msg:
await _create_ticket_and_attach_files(user_id, session["msg_text"], session, msg, login)
try:
await _create_ticket_and_attach_files(user_id, session["msg_text"], session, msg, login)
except Exception as e:
logger.exception(f"❌ [SD Ticket Failed] user={user_id}: {e}")
sd_workflow_logger.error(f"❌ [SD Ticket] {e}")
_sd_log_and_notify_email(f"Создание заявки для {user_id}", str(e))
session["step"] = "need_text"
try:
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
except Exception:
sd_workflow_logger.error(f"❌ [SD] Failed to send error message to {user_id}")
def _on_sd_timer_done(task: asyncio.Task, user_id: str):
"""Безопасный коллбэк завершения таймера создания заявки"""
try:
task.result()
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"❌ Ошибка в таске таймера SD для {user_id}: {e}")
sd_workflow_logger.error(f"❌ [SD Timer Error] {user_id}: {e}")
_sd_log_and_notify_email(f"Таймер создания заявки для {user_id}", str(e))
@router.message()
async def sd_module_handler(msg: Message):
user_id = msg.from_user.id
sd_workflow_logger.info(f"🚀 [SD Workflow Start] User: {user_id}")
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": []}
session = sd_sessions[user_id]
# --- ШАГ 1: ОЖИДАНИЕ ТЕКСТА ---
if session["step"] == "need_text":
if not msg_text.strip() and not inline_attachments:
await msg.answer(SD_UNKNOWN_CMD_TEXT, parse_mode="html")
try:
user_id = msg.from_user.id
sd_workflow_logger.info(f"🚀 [SD Workflow Start] User: {user_id}")
if states.get_state(user_id) != "SD_MODE":
return
# Если только вложение без текста — сохраняем и ждем текст
if not msg_text.strip() and inline_attachments:
session["files_queue"].extend(inline_attachments)
sd_workflow_logger.info(f"📎 [SD] Saved {len(inline_attachments)} attachment(s), waiting for text")
await msg.answer(SD_TEXT_REQUIRED, parse_mode="html")
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
# --- ВЫХОД ---
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 inline_attachments:
session["files_queue"].extend(inline_attachments)
sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue")
if user_id not in sd_sessions:
sd_sessions[user_id] = {"step": "need_text", "files_queue": [], "post_create_queue": []}
session = sd_sessions[user_id]
# Настраиваем параметры сессии для ожидания
session["step"] = "waiting_for_attachments"
session["msg_text"] = msg_text
session["msg"] = msg
session["user_id"] = user_id
session["login"] = login
# --- ШАГ 1: ОЖИДАНИЕ ТЕКСТА ---
if session["step"] == "need_text":
if not msg_text.strip() and not inline_attachments:
await msg.answer(SD_UNKNOWN_CMD_TEXT, parse_mode="html")
return
# Сразу выдаем ОДНО сообщение пользователю, чтобы он видел реакцию бота
await msg.answer(SD_CREATING, parse_mode="html")
if not msg_text.strip() and inline_attachments:
session["files_queue"].extend(inline_attachments)
sd_workflow_logger.info(f"📎 [SD] Saved {len(inline_attachments)} attachment(s), waiting for text")
await msg.answer(SD_TEXT_REQUIRED, parse_mode="html")
return
# Запускаем фоновый таймер с механизмом сброса (Debounce)
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
if inline_attachments:
session["files_queue"].extend(inline_attachments)
sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue")
# --- ШАГ 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")
session["step"] = "waiting_for_attachments"
session["msg_text"] = msg_text
session["msg"] = msg
session["user_id"] = user_id
session["login"] = login
# Перезапускаем таймер (пользователь активен, сдвигаем окно создания вперед)
if "timer_task" in session and not session["timer_task"].done():
session["timer_task"].cancel()
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
sd_workflow_logger.info(f"🔄 [SD] Timer reset due to user activity. Waiting another {ATTACHMENT_WAIT_TIMEOUT}s")
return
await msg.answer(SD_CREATING, parse_mode="html")
# --- ШАГ 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
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
+67 -4
View File
@@ -7,7 +7,9 @@ import httpx
from ldap3 import Server, Connection, ALL
from trueconf import Router, Message
import config.config as config
from config.config import *
from config.config import DEFAULT_EMAIL_FROM, SMTP_SERVER, SMTP_PORT, ALERTS_SUPPORT_EMAILS
from utils import states
from utils.menu import MENU_TEXT
from utils.texts import (
@@ -16,14 +18,64 @@ from utils.texts import (
transcription_error_bad_extension,
TRANSCRIPTION_UPLOADING_TEXT,
transcription_success_received,
transcription_error_api,
transcription_error_system,
system_error_text,
UNKNOWN_MAIN_CMD_TEXT,
)
# 🔌 Импортируем централизованную функцию сбора статистики из main
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__)
router = Router()
@@ -51,6 +103,7 @@ def get_user_email_sync(login: str) -> str:
return str(entries[0].mail.value)
except Exception as e:
logger.error(f"Ошибка поиска email в AD: {e}")
_transcription_log_and_notify_email("Поиск email в AD", str(e))
return DEFAULT_REQUESTER
@router.message()
@@ -115,12 +168,22 @@ async def transcription_handler(msg: Message):
log_menu_stats(user_id, "Speech-to-Text", f"Отправка встречи на расшифровку ({ext})")
await msg.answer(transcription_success_received(user_email), parse_mode="html")
else:
await msg.answer(transcription_error_api(response.status_code), parse_mode="html")
_transcription_log_and_notify_email(f"API транскрипции вернул статус {response.status_code}", str(response.text[:500]))
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
except Exception as e:
logger.error(f"Transcription Upload Error: {e}")
await msg.answer(transcription_error_system(), parse_mode="html")
_transcription_log_and_notify_email("Загрузка/отправка файла на транскрипцию", str(e))
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
return
# --- ЗАГЛУШКА НА НЕИЗВЕСТНЫЙ ТЕКСТ / СТАНДАРТНАЯ ОШИБКА ВВОДА ---
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
Binary file not shown.
Binary file not shown.
+1
View File
@@ -10,6 +10,7 @@ PHOTO_MODE = "PHOTO_MODE"
SD_MODE = "SD_MODE"
TRANSCRIPTION_MODE = "TRANSCRIPTION_MODE"
SEARCH_MODE = "SEARCH_MODE"
INSTRUCT_EMAIL_HISTORY = "INSTRUCT_EMAIL_HISTORY"
def set_state(user_id, state):
"""Фиксирует новое состояние пользователя и обновляет метку времени активности"""
+86 -62
View File
@@ -2,18 +2,16 @@
# Все текстовые сообщения бота, отправляемые пользователю
# =========================================================
# GLOBAL CONSTANTS
# GLOBAL CONSTANTS & HELPERS
# =========================================================
# --- Main Bot ---
# Общее для главного бота
UNKNOWN_MAIN_CMD_TEXT = (
"⚠️ <b>Неизвестная команда.</b>\n\n"
"Пожалуйста, используйте только цифры, соответствующие нужным пунктам меню, и отправьте выбранную цифру мне в сообщении."
)
# --- Emoji Digits ---
# Используются для навигации и выбора пунктов меню
EMOJI_DIGITS = {
"0": "0⃣",
"1": "1⃣",
@@ -29,7 +27,6 @@ EMOJI_DIGITS = {
}
# --- Footer Templates ---
# Шаблоны подвалов сообщений для навигации
def _footer(emojis: dict) -> str:
"""Стандартный футер с кнопками Назад и Меню"""
@@ -39,6 +36,17 @@ def _footer_main_only(emojis: dict) -> str:
"""Футер только с кнопкой Меню"""
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
@@ -209,14 +217,6 @@ def _photo_unknown(emojis: dict) -> str:
f"{emojis['0']}В главное меню"
)
def _photo_error_handling(emojis: dict) -> str:
return (
f"⚠️ <i>В данный момент выполнить это действие невозможно. "
f"Информация о проблеме передана ответственным.\n"
f"Приносим извинения за доставленные неудобства.</i>\n\n"
f"{emojis['0']}В главное меню"
)
def _photo_uploading() -> str:
return "⏳ Отправка..."
@@ -253,7 +253,7 @@ PHOTO_MAIN_MENU_TEXT = _photo_welcome(EMOJI_DIGITS)
PHOTO_UNKNOWN_CMD_TEXT = _photo_unknown(EMOJI_DIGITS)
def photo_error_handling() -> str:
return _photo_error_handling(EMOJI_DIGITS)
return system_error_text(EMOJI_DIGITS, show_back=False)
PHOTO_UPLOADING_TEXT = _photo_uploading()
@@ -319,14 +319,6 @@ def _search_feedback_info(emojis: dict) -> str:
f"{emojis['0']} — Выйти в главное меню"
)
def _search_unavailable(emojis: dict) -> str:
return (
"⚠️ <i>В данный момент выполнить это действие невозможно. "
"Информация о проблеме передана ответственным.\n"
"Приносим извинения за доставленные неудобства.</i>\n\n"
f"<i>{emojis['0']} — В главное меню</i>"
)
# --- Public constants and functions ---
SEARCH_MAIN_MENU_TEXT = _search_welcome(EMOJI_DIGITS)
@@ -345,7 +337,7 @@ def search_feedback_info_text(emojis: dict) -> str:
return _search_feedback_info(emojis)
def search_unavailable_action(emojis: dict) -> str:
return _search_unavailable(emojis)
return system_error_text(emojis, show_back=False)
# =========================================================
@@ -353,8 +345,6 @@ def search_unavailable_action(emojis: dict) -> str:
# =========================================================
# Модуль Личного Кабинета (финансовые документы, табель и др.)
# --- Constants ---
DOC_TYPES = {
"1": "Справка о доходах физического лица (2-НДФЛ)",
"2": "Справка об удержаниях за ДМС",
@@ -367,12 +357,6 @@ DOC_TYPES = {
# --- Private functions (Internal logic) ---
def _lk_error_handling(emojis: dict) -> str:
return (
"⚠️ <i>В данный момент выполнить это действие невозможно. Информация о проблеме передана ответственным. Приносим извинения за доставленные неудобства.</i>\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
def _lk_auth_processing(emojis: dict) -> str:
return "⏳ <i>Проверка безопасности... Запрашиваю контакты авторизации...</i>"
@@ -499,9 +483,8 @@ LK_UNKNOWN_CMD_TEXT = (
f"<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
)
def lk_error_handling(emojis: dict) -> str:
return _lk_error_handling(emojis)
return system_error_text(emojis, show_back=True)
def lk_auth_processing(emojis: dict) -> str:
return _lk_auth_processing(emojis)
@@ -592,58 +575,88 @@ def lk_years_menu(current_year: int, emojis: dict) -> str:
def _instruct_main_menu(emojis: dict) -> str:
return (
f"📋 <b>Выберите инструкцию:</b>\n\n"
f"{emojis['1']} — 🖥 Trueconf\n"
f"{emojis['2']} — 📧 Почта\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"
"🖥 <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 с проверкой группы 2FA.
cn — CN пользователя из AD.
ticket_id — номер созданной заявки в SD.
Чистый шаблон сообщения Trueconf:
- Если ticket_id передан: показываем статус заявки в ServiceDesk.
- Если ticket_id is None: у пользователя есть доступ, выводим подпись к файлу.
"""
from utils.ad_checker import check_group_membership
result = check_group_membership(cn, "2FA")
if not result["found"]:
ticket_part = f"\n\n🎫 <b>Заявка #{ticket_id} создана!</b>" if ticket_id else ""
if ticket_id:
return (
f"🖥 <b>Инструкция по Trueconf</b>\n\n"
f"Пользователь {cn} не найден в Active Directory.{ticket_part}\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
if result["in_group"]:
body = (
f"🖥 <b>Инструкция по Trueconf</b>\n\n"
f" <b>У Вас отсутствует доступ к TrueConf с мобильных устройств.</b>\n\n"
f"🎫 Заявка #{ticket_id} для настройки создана в ServiceDesk!\n\n"
f"После выполнения заявки воспользуйтесь приложенной к этому сообщению инструкцией.\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
else:
ticket_part = f"\n\n🎫 <b>Заявка #{ticket_id} на предоставление доступа создана в ServiceDesk!</b>" if ticket_id else ""
body = (
f"❌ <b>Доступ к Trueconf запрещён.</b>\n\n"
f"У вас нет группы 2FA в Active Directory.{ticket_part}\n\n"
return (
f"🖥 <b>Инструкция по настройке TrueConf на мобильном устройстве</b>\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
return body
def _instruct_email(emojis: dict) -> str:
def _instruct_email_available(emojis: dict, cn: str, mail: str) -> str:
"""Текст когда почта доступна."""
return (
"📧 <b>Инструкция по Почте</b>\n\n"
"Данная функция находится в разработке.\n\n"
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>"
)
@@ -652,4 +665,15 @@ def _instruct_email(emojis: dict) -> str:
INSTRUCT_MAIN_MENU_TEXT = _instruct_main_menu(EMOJI_DIGITS)
INSTRUCT_TRUECONF_TEXT = _instruct_trueconf(EMOJI_DIGITS)
INSTRUCT_TRUECONF_WITH_AD = _instruct_trueconf_with_ad
INSTRUCT_EMAIL_TEXT = _instruct_email(EMOJI_DIGITS)
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)