from utils.texts import * # /opt/trueconf_bot/lk/handlers.py import re import os import csv import logging import asyncio import time import smtplib from datetime import datetime, timedelta from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from ldap3 import Server, Connection, ALL from ldap3.utils.conv import escape_filter_chars from trueconf import Router, Message from trueconf.types import FSInputFile import config.config as config import lk.payslip_service as ps import lk.documents_service as ds from utils.states import get_state, set_state, clear_state # 🔌 Импортируем централизованную функцию сбора статистики из main from utils.stats_logger import log_menu_stats # 🔌 Импортируем функции безопасности из изолированного утилитного модуля 2FA from utils.otp_service import generate_otp_code, send_otp_via_api, get_user_phone_from_ad # 🔌 Импортируем вынесенную бизнес-логику запросов по CARD_ID from lk.info_service import ( get_vacation_records, get_rewards_text, get_sick_leaves_text, get_tabel_report_text, get_lk_profile_by_card_id, get_missions_records, get_transfers_records ) logger = logging.getLogger(__name__) # ========================================================= # ГЛОБАЛЬНЫЙ МАРШРУТИЗАТОР ЦЕЛЬНЫХ VK-ЭМОДЗИ И СЕССИЙ ПАГИНАЦИИ # ========================================================= EMOJI_DIGITS = { "0": "0⃣", "1": "1⃣", "2": "2⃣", "3": "3⃣", "4": "4⃣", "5": "5⃣", "6": "6⃣", "7": "7⃣", "8": "8⃣", "9": "9⃣", "10": "🔟" } # Список из 7 справок строго по структуре портала DOC_TYPES = { "1": "Справка о доходах физического лица (2-НДФЛ)", "2": "Справка об удержаниях за ДМС", "3": "Справка о средней заработной плате", "4": "Справка о получении единовременного пособия по рождению ребенка", "5": "Справка о получении ежемесячного пособия и назначении отпуска по уходу за ребенком", "6": "Справка об удержаниях по исполнительным листам", "7": "Копия трудовой книжки" } LK_DOCS_MENU_TEXT = ( "📄 Выберите тип документа:\n\n" + "\n".join([f"{EMOJI_DIGITS[k]} — {v}" for k, v in DOC_TYPES.items()]) + f"\n\n{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню" ) USER_COOLDOWN_SEC = 0.5 EMAIL_COOLDOWN_SEC = 10.0 user_last_request = {} last_email_time = 0.0 user_docs_session = {} user_page_sessions = {} # 🔒 Репозиторий временных блокировок 2FA (хранит user_id: timestamp_разблокировки) user_payslip_blocks = {} router = Router() # ========================================================= # СИСТЕМНЫЕ ФУНКЦИИ И ОШИБКИ # ========================================================= def log_and_notify_error(user_id: str, action: str, error_details: str): global last_email_time log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "lk", "logs") os.makedirs(log_dir, exist_ok=True) log_file = os.path.join(log_dir, "lk_errors.log") current_time = time.time() timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") log_entry = f"[{timestamp}] User: {user_id} | Action: {action} | Error: {error_details}\n" with open(log_file, "a", encoding="utf-8") as f: f.write(log_entry) if current_time - last_email_time < EMAIL_COOLDOWN_SEC: return last_email_time = current_time try: msg = MIMEMultipart() msg['From'] = str(getattr(config, 'DEFAULT_EMAIL_FROM', 'bot@noreply.com')) to_emails = getattr(config, 'ALERTS_SUPPORT_EMAILS', 'admin@example.com') 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Пользователь: {user_id}\nДействие: {action}\nДетали:\n{error_details}" msg.attach(MIMEText(body, 'plain', 'utf-8')) import lk.info_service as info_service with smtplib.SMTP(getattr(config, 'SMTP_SERVER', 'localhost'), int(getattr(config, 'SMTP_PORT', 25))) as server: server.send_message(msg) except Exception: pass 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) await msg.answer(error_msg, parse_mode="html") # ========================================================= # ИНТЕГРАЦИЯ С ACTIVE DIRECTORY ЧЕРЕЗ extensionAttribute2 # ========================================================= def get_card_id_from_ad(user_id: str) -> str: 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, attributes=['extensionAttribute2']) if entries and 'extensionAttribute2' in entries[0] and entries[0].extensionAttribute2.value: return str(entries[0].extensionAttribute2.value).strip() raise Exception("Поле extensionAttribute2 не заполнено в AD.") except Exception as e: raise Exception(f"AD Card Query Error: {e}") # ========================================================= # МОДНОЕ УНИВЕРСАЛЬНОЕ ЯДРО ПАГИНАЦИИ СПИСКОВ ДЛЯ ЧАТА # ========================================================= async def send_paginated_page(msg: Message, user_id: str): session = user_page_sessions.get(user_id) if not session: return current_page = session["current_page"] per_page = session["per_page"] items = session["items"] total_items = len(items) total_pages = (total_items + per_page - 1) // per_page if total_items > 0 else 1 start_idx = current_page * per_page end_idx = start_idx + per_page page_items = items[start_idx:end_idx] if session["type"] == "vacation": body_text = session["static_header"] + "\n📜 История отпусков (полный архив):\n" body_text += "\n".join(page_items) + "\n" if page_items else "• История отпусков пуста\n" elif session["type"] == "missions": body_text = "🧳 Информация о командировках (архив):\n\n" body_text += "\n\n".join(page_items) + "\n" if page_items else "• Записи о командировках отсутствуют\n" elif session["type"] == "transfers": body_text = "🔄 История кадровых перемещений:\n\n" body_text += "\n\n".join(page_items) + "\n" if page_items else "• Записи о перемещениях отсутствуют\n" body_text += f"\n📄 Страница {current_page + 1} из {total_pages}\n\n" nav_lines = [] if current_page > 0: nav_lines.append(f"{EMOJI_DIGITS['7']} — Предыдущая страница") if current_page < total_pages - 1: nav_lines.append(f"{EMOJI_DIGITS['8']} — Следующая страница") nav_lines.append(f"{EMOJI_DIGITS['9']} — Назад") nav_lines.append(f"{EMOJI_DIGITS['0']} — В главное меню") body_text += "\n".join(nav_lines) await msg.answer(body_text, parse_mode="html") # ========================================================= # ДИНАМИЧЕСКАЯ СБОРКА ГЛАВНОГО МЕНЮ ЛК # ========================================================= async def send_dynamic_lk_main_menu(msg: Message, user_id: str): try: card_id = await asyncio.to_thread(get_card_id_from_ad, user_id) profile = await asyncio.to_thread(get_lk_profile_by_card_id, card_id) header = ( f"👤 Личный кабинет\n\n" f"🏢 Компания: {profile['company']}\n" f"📂 Отдел: {profile['dept']}\n" f"💼 Должность: {profile['job']}\n" f"⏳ Страховой стаж: {profile['stag_o']}\n" f"📈 Стаж на предприятии: {profile['stag_p']}\n\n" ) except Exception as e: logger.error(f"Не удалось собрать динамический заголовок ЛК: {e}") header = "👤 Личный кабинет\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) 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} г." menu_text = ( "⏱ Выберите месяц для просмотра табеля:\n\n" f"{EMOJI_DIGITS['1']} — {m1_str}\n" f"{EMOJI_DIGITS['2']} — {m2_str}\n\n" f"{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню" ) set_state(user_id, "LK_TABEL_MONTHS") await msg.answer(menu_text, parse_mode="html") def wrapper_get_months(card_id: str) -> list: import lk.info_service as info_service conn = info_service.get_db_connection() cursor = conn.cursor() try: return ps.get_latest_payslip_months(cursor, card_id) finally: conn.close() def wrapper_get_payslip(card_id: str, target_cmonth: int): import lk.info_service as info_service conn = info_service.get_db_connection() cursor = conn.cursor() try: return ps.build_payslip_data(cursor, card_id, target_cmonth) except Exception as e: raise Exception(f"Payslip Build Error: {e}") finally: conn.close() async def send_payslip_months_menu(msg: Message, user_id: str, card_id: str): try: months_data = await asyncio.to_thread(wrapper_get_months, card_id) months_ru = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь", 7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"} menu_text = lk_payslip_months_menu_text(months_data, months_ru, EMOJI_DIGITS) set_state(user_id, "LK_PAYSLIP_MONTHS") await msg.answer(menu_text, parse_mode="html") except Exception as e: raise Exception(str(e)) async def build_confirmation_text(user_id: str, doc: dict) -> str: confirm_txt = f"📝 Подтверждение заказа:\n\nДокумент: {doc['doc_name']}\nКоличество: {doc['quantity']} шт." if doc.get('period'): confirm_txt += f"\nУточнение: {doc['period']}" if getattr(config, 'DOCUMENTS_TEST_MODE', False): try: card_id = await asyncio.to_thread(get_card_id_from_ad, user_id) import lk.info_service as info_service with info_service.get_db_connection() as conn: cursor = conn.cursor() user_info = await asyncio.to_thread(ds.get_user_info_for_docs, cursor, card_id) orig_to = user_info.get('hr_email', '').strip() orig_cc = user_info.get('wb_email', '').strip() if doc['doc_id'] == "7" else "" requested_recipients = orig_to if orig_cc: requested_recipients += f" (Копия: {orig_cc})" test_email = getattr(config, 'DOCUMENTS_TARGET_EMAIL', 'mailonline@sibcem.ru') confirm_txt += f"\n\n⚠️ Тест-режим.\nКому: {requested_recipients}\nЗамена: {test_email}" except Exception as e: confirm_txt += f"\n\n⚠️ Тест-режим.\n(Ошибка БД: {e})" confirm_txt += f"\n\n{EMOJI_DIGITS['1']} — Да, отправить\n{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню" return confirm_txt # ========================================================= # ОСНОВНОЙ ОБРАБОТЧИК МАРШРУТОВ ЛК # ========================================================= @router.message() async def lk_router_handler(msg: Message): user_id = msg.from_user.id current_time = time.time() current_state = get_state(user_id) # Если пользователь находится внутри меню ЛК, помечаем сообщение обработанным, # чтобы оно не улетало в ServiceDesk / Instruct роутеры if current_state and current_state.startswith("LK_"): msg.handled = True # Проверка на слишком частые клики (кулдаун) if current_time - user_last_request.get(user_id, 0) < USER_COOLDOWN_SEC: return user_last_request[user_id] = current_time nav_text = lk_nav_text(EMOJI_DIGITS) 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) == "LK": msg.handled = True set_state(user_id, "LK_MODE") await send_dynamic_lk_main_menu(msg, user_id) return return if current_state == "LK_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": block_expiry = user_payslip_blocks.get(user_id, 0) if time.time() < block_expiry: rem_minutes = int((block_expiry - time.time()) // 60) + 1 await msg.answer( f"❌ Доступ временно заблокирован.\n" f"Вы трижды ввели неверный код подтверждения.\n" f"Доступ к расчетным листам ограничен на {int(getattr(config, 'OTP_BLOCK_DURATION_SEC', 1800) // 60)} минут.\n\n" f"⏱ Осталось подождать: {rem_minutes} мин.{nav_text}", parse_mode="html" ) return log_menu_stats(user_id, "Личный кабинет", "Расчётный лист") await msg.answer(lk_payslip_loading(EMOJI_DIGITS), parse_mode="html") try: card_id = await asyncio.to_thread(get_card_id_from_ad, user_id) await send_payslip_months_menu(msg, user_id, card_id) except Exception as e: await handle_lk_error(msg, user_id, "Меню расчетных листков", str(e)) elif cmd == "2": log_menu_stats(user_id, "Личный кабинет", "Информация о днях отпуска") await msg.answer(lk_vacation_loading(EMOJI_DIGITS), parse_mode="html") try: card_id = await asyncio.to_thread(get_card_id_from_ad, user_id) static_header, history_lines = await asyncio.to_thread(get_vacation_records, card_id) user_page_sessions[user_id] = {"type": "vacation", "current_page": 0, "per_page": 5, "static_header": static_header, "items": history_lines} set_state(user_id, "LK_VACATION_VIEW") await send_paginated_page(msg, user_id) except Exception as e: await handle_lk_error(msg, user_id, "Справка по отпускам", str(e)) elif cmd == "3": log_menu_stats(user_id, "Личный кабинет", "Больничные") await msg.answer(lk_sick_loading(EMOJI_DIGITS), parse_mode="html") try: card_id = await asyncio.to_thread(get_card_id_from_ad, user_id) sick_info = await asyncio.to_thread(get_sick_leaves_text, card_id) set_state(user_id, "LK_SICK_VIEW") await msg.answer(sick_info + nav_text, parse_mode="html") except Exception as e: await handle_lk_error(msg, user_id, "Просмотр больничных", str(e)) elif cmd == "4": log_menu_stats(user_id, "Личный кабинет", "Командировки") await msg.answer(lk_missions_loading(EMOJI_DIGITS), parse_mode="html") try: card_id = await asyncio.to_thread(get_card_id_from_ad, user_id) mission_lines = await asyncio.to_thread(get_missions_records, card_id) user_page_sessions[user_id] = {"type": "missions", "current_page": 0, "per_page": 4, "items": mission_lines} set_state(user_id, "LK_MISSIONS_VIEW") await send_paginated_page(msg, user_id) except Exception as e: await handle_lk_error(msg, user_id, "Просмотр командировок", str(e)) elif cmd == "5": log_menu_stats(user_id, "Личный кабинет", "Награды") await msg.answer(lk_rewards_loading(EMOJI_DIGITS), parse_mode="html") try: card_id = await asyncio.to_thread(get_card_id_from_ad, user_id) rewards_info = await asyncio.to_thread(get_rewards_text, card_id) set_state(user_id, "LK_REWARDS_VIEW") await msg.answer(rewards_info + nav_text, parse_mode="html") except Exception as e: await handle_lk_error(msg, user_id, "Запрос наград", str(e)) elif cmd == "6": log_menu_stats(user_id, "Личный кабинет", "Переводы") await msg.answer(lk_transfers_loading(EMOJI_DIGITS), parse_mode="html") try: card_id = await asyncio.to_thread(get_card_id_from_ad, user_id) transfer_lines = await asyncio.to_thread(get_transfers_records, card_id) user_page_sessions[user_id] = {"type": "transfers", "current_page": 0, "per_page": 4, "items": transfer_lines} set_state(user_id, "LK_TRANSFERS_VIEW") await send_paginated_page(msg, user_id) except Exception as e: await handle_lk_error(msg, user_id, "Просмотр переводов", str(e)) elif cmd == "7": log_menu_stats(user_id, "Личный кабинет", "Табель учёта рабочего времени") await send_tabel_months_menu(msg, user_id) elif cmd == "8": log_menu_stats(user_id, "Личный кабинет", "Справки") 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") elif current_state == "LK_TABEL_MONTHS": 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, "LK_MODE") await send_dynamic_lk_main_menu(msg, user_id) elif cmd in ["1", "2"]: log_menu_stats(user_id, "Личный кабинет", f"Просмотр табеля (вариант {cmd})") 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 ) set_state(user_id, "LK_TABEL_VIEW") await msg.answer(tabel_report + nav_text, parse_mode="html") except Exception as e: await handle_lk_error(msg, user_id, "Формирование табеля", str(e)) else: await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html") elif current_state in ["LK_VACATION_VIEW", "LK_MISSIONS_VIEW", "LK_TRANSFERS_VIEW"]: msg.handled = True session = user_page_sessions.get(user_id) if cmd == "0": user_page_sessions.pop(user_id, None) clear_state(user_id) from utils.menu import MENU_TEXT await msg.answer(MENU_TEXT, parse_mode="html") elif cmd == "9": user_page_sessions.pop(user_id, None) set_state(user_id, "LK_MODE") await send_dynamic_lk_main_menu(msg, user_id) elif cmd == "7" and session and session["current_page"] > 0: session["current_page"] -= 1 await send_paginated_page(msg, user_id) elif cmd == "8" and session: if session["current_page"] < (len(session["items"]) + session["per_page"] - 1) // session["per_page"] - 1: session["current_page"] += 1 await send_paginated_page(msg, user_id) else: await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html") elif current_state == "LK_PAYSLIP_AUTH": msg.handled = True session = user_page_sessions.get(user_id) if cmd == "0": user_page_sessions.pop(user_id, None) clear_state(user_id) from utils.menu import MENU_TEXT await msg.answer(MENU_TEXT, parse_mode="html") return elif cmd == "9": user_page_sessions.pop(user_id, None) set_state(user_id, "LK_PAYSLIP_MONTHS") try: card_id = await asyncio.to_thread(get_card_id_from_ad, user_id) await send_payslip_months_menu(msg, user_id, card_id) except Exception as e: await handle_lk_error(msg, user_id, "Возврат к меню месяцев", str(e)) return if not session: await msg.answer("⚠️ Сессия авторизации не найдена. Начните сначала.", parse_mode="html") set_state(user_id, "LK_MODE") await send_dynamic_lk_main_menu(msg, user_id) return if time.time() > session.get("expires_at", 0): log_menu_stats(user_id, "Личный кабинет", "Ошибка 2FA: Истекло время жизни кода") user_page_sessions.pop(user_id, None) set_state(user_id, "LK_MODE") await msg.answer( f"⏱ Время действия кода подтверждения изменилось или истекло.\n" f"Пожалуйста, запросите расчетный лист заново.{nav_text}", parse_mode="html" ) await send_dynamic_lk_main_menu(msg, user_id) return if cmd == session.get("correct_code"): log_menu_stats(user_id, "Личный кабинет", "Успешная проверка 2FA расчетного листа") await msg.answer(lk_auth_success(EMOJI_DIGITS), parse_mode="html") try: card_id = await asyncio.to_thread(get_card_id_from_ad, user_id) target_cmonth = session["target_cmonth"] m_name = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь", 7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"} tmonth = (target_cmonth - 1) % 12 + 1 tyear = (target_cmonth - 1) // 12 payslip_info, pdf_path = await asyncio.to_thread(wrapper_get_payslip, card_id, target_cmonth) set_state(user_id, "LK_PAYSLIP_VIEW") await msg.answer_document( FSInputFile(pdf_path, filename=f"Расчетный_лист_{m_name.get(tmonth, '00')}_{tyear}.pdf"), caption=payslip_info + f"\n\n{EMOJI_DIGITS['9']} — Назад к выбору месяца\n{EMOJI_DIGITS['0']} — В главное меню", parse_mode="html" ) if os.path.exists(pdf_path): os.remove(pdf_path) except Exception as e: await handle_lk_error(msg, user_id, "Просмотр детализации после 2FA", str(e)) finally: user_page_sessions.pop(user_id, None) else: session["attempts_left"] -= 1 if session["attempts_left"] <= 0: log_menu_stats(user_id, "Личный кабинет", "Ошибка 2FA: Превышен лимит попыток ввода, бан") block_duration = int(getattr(config, 'OTP_BLOCK_DURATION_SEC', 1800)) user_payslip_blocks[user_id] = time.time() + block_duration user_page_sessions.pop(user_id, None) set_state(user_id, "LK_MODE") await msg.answer( f"❌ Превышено максимальное количество попыток ввода кода.\n" f"Доступ к финансовым документам заблокирован на {block_duration // 60} минут.{nav_text}", parse_mode="html" ) await send_dynamic_lk_main_menu(msg, user_id) else: await msg.answer( f"⚠️ Неверный код подтверждения.\n" f"Осталось попыток: {session['attempts_left']}. Попробуйте еще раз или вернитесь назад.{nav_text}", parse_mode="html" ) elif current_state in ["LK_REWARDS_VIEW", "LK_PAYSLIP_MONTHS", "LK_SICK_VIEW", "LK_TABEL_VIEW", "LK_STUB_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": if current_state == "LK_TABEL_VIEW": await send_tabel_months_menu(msg, user_id) else: set_state(user_id, "LK_MODE"); await send_dynamic_lk_main_menu(msg, user_id) elif current_state == "LK_PAYSLIP_MONTHS" and cmd.isdigit(): try: card_id = await asyncio.to_thread(get_card_id_from_ad, user_id) months_data = await asyncio.to_thread(wrapper_get_months, card_id) idx = int(cmd) - 1 if 0 <= idx < len(months_data): target_cmonth = months_data[idx] await msg.answer(lk_auth_processing(EMOJI_DIGITS), parse_mode="html") user_phone = await asyncio.to_thread(get_user_phone_from_ad, user_id) otp_code = generate_otp_code(4) sms_success, channel_info = await send_otp_via_api(user_phone, otp_code) if sms_success: log_menu_stats(user_id, "Личный кабинет", f"Запрос кода 2FA для расчетного листа ({channel_info})") masked_phone = f"{user_phone[:5]}***{user_phone[-4:]}" if len(user_phone) > 8 else user_phone otp_ttl = int(getattr(config, 'OTP_TTL_SEC', 180)) max_attempts = int(getattr(config, 'OTP_MAX_ATTEMPTS', 3)) user_page_sessions[user_id] = { "correct_code": otp_code, "target_cmonth": target_cmonth, "expires_at": time.time() + otp_ttl, "attempts_left": max_attempts } set_state(user_id, "LK_PAYSLIP_AUTH") await msg.answer( f"🔐 Двухфакторная авторизация\n\n" f"Код подтверждения отправлен {channel_info} на номер {masked_phone}.\n" f"Пожалуйста, введите 4-значный код из сообщения в ответ:\n\n" f"Код действителен в течение {otp_ttl // 60} минут. У вас есть {max_attempts} попытки.{nav_text}", parse_mode="html" ) else: raise Exception(f"Внутренний каскадный шлюз недоступен: {channel_info}") else: await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html") 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") elif current_state == "LK_PAYSLIP_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": try: card_id = await asyncio.to_thread(get_card_id_from_ad, user_id); await send_payslip_months_menu(msg, user_id, card_id) except Exception as e: await handle_lk_error(msg, user_id, "Возврат в меню месяцев", str(e)) else: await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html") elif current_state == "LK_DOCS_MENU": msg.handled = True if cmd == "0": user_docs_session.pop(user_id, None); clear_state(user_id); from utils.menu import MENU_TEXT; await msg.answer(MENU_TEXT, parse_mode="html") elif cmd == "9": user_docs_session.pop(user_id, None); set_state(user_id, "LK_MODE"); await send_dynamic_lk_main_menu(msg, user_id) elif cmd in DOC_TYPES: user_docs_session[user_id] = {'doc_id': cmd, 'doc_name': DOC_TYPES[cmd]} log_menu_stats(user_id, "Личный кабинет", f"Выбор справки: {DOC_TYPES[cmd]}") set_state(user_id, "LK_DOCS_QUANTITY") await msg.answer(lk_docs_quantity_prompt(EMOJI_DIGITS), parse_mode="html") else: await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html") elif current_state == "LK_DOCS_QUANTITY": msg.handled = True if cmd == "0": user_docs_session.pop(user_id, None); 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.isdigit() and 0 < int(cmd) <= 5: user_docs_session[user_id]['quantity'] = int(cmd) doc_id = user_docs_session[user_id]['doc_id'] if doc_id in ["1", "2", "6"]: set_state(user_id, "LK_DOCS_PERIOD") current_year = datetime.now().year years_text = lk_years_menu(current_year, EMOJI_DIGITS) await msg.answer(years_text, parse_mode="html") elif doc_id == "3": set_state(user_id, "LK_DOCS_PERIOD") await msg.answer(lk_docs_avg_salary_prompt(EMOJI_DIGITS), parse_mode="html") elif doc_id == "5": set_state(user_id, "LK_DOCS_PERIOD") await msg.answer(lk_docs_child_age_prompt(EMOJI_DIGITS), parse_mode="html") else: user_docs_session[user_id]['period'] = "" set_state(user_id, "LK_DOCS_CONFIRM") confirm_txt = await build_confirmation_text(user_id, user_docs_session[user_id]) await msg.answer(confirm_txt, parse_mode="html") else: await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html") elif current_state == "LK_DOCS_PERIOD": msg.handled = True if cmd == "0": user_docs_session.pop(user_id, None); 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_QUANTITY"); await msg.answer(lk_docs_quantity_prompt(EMOJI_DIGITS), parse_mode="html") else: doc_id = user_docs_session[user_id]['doc_id'] if doc_id in ["1", "2", "6"]: if cmd in ["1", "2", "3", "4", "5", "6", "7", "8"]: user_docs_session[user_id]['period'] = f"за {datetime.now().year - (int(cmd) - 1)} год" else: await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html") return elif doc_id == "5": if cmd == "1": user_docs_session[user_id]['period'] = "до 1.5 лет" elif cmd == "2": user_docs_session[user_id]['period'] = "до 3 лет" else: await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html") return elif doc_id == "3": user_docs_session[user_id]['period'] = f"за {cmd} мес." set_state(user_id, "LK_DOCS_CONFIRM") confirm_txt = await build_confirmation_text(user_id, user_docs_session[user_id]) await msg.answer(confirm_txt, parse_mode="html") elif current_state == "LK_DOCS_CONFIRM": msg.handled = True if cmd == "0": user_docs_session.pop(user_id, None); clear_state(user_id); from utils.menu import MENU_TEXT; await msg.answer(MENU_TEXT, parse_mode="html") elif cmd == "9": doc_id = user_docs_session.get(user_id, {}).get('doc_id') if doc_id in ["1", "2", "3", "5", "6"]: set_state(user_id, "LK_DOCS_PERIOD") if doc_id in ["1", "2", "6"]: current_year = datetime.now().year years_text = lk_years_menu(current_year, EMOJI_DIGITS) await msg.answer(years_text, parse_mode="html") elif doc_id == "3": await msg.answer(lk_docs_avg_salary_prompt(EMOJI_DIGITS), parse_mode="html") elif doc_id == "5": await msg.answer(lk_docs_child_age_prompt(EMOJI_DIGITS), parse_mode="html") else: set_state(user_id, "LK_DOCS_QUANTITY"); await msg.answer(lk_docs_quantity_prompt(EMOJI_DIGITS), parse_mode="html") elif cmd == "1": await msg.answer("⏳ Отправка заявки...", parse_mode="html") try: doc = user_docs_session[user_id] card_id = await asyncio.to_thread(get_card_id_from_ad, user_id) import lk.info_service as info_service with info_service.get_db_connection() as conn: cursor = conn.cursor() user_info = await asyncio.to_thread(ds.get_user_info_for_docs, cursor, card_id) success = await asyncio.to_thread(ds.generate_mail_message, doc['doc_name'], doc['quantity'], user_info, doc.get('period', '')) if success: log_menu_stats(user_id, "Личный кабинет", f"Заказ справки: {doc['doc_name']}") await msg.answer(f"✅ Ваша заявка успешно отправлена!\nСпециалисты свяжутся с вами по готовности документа.\n\n{EMOJI_DIGITS['9']} — Назад в список справок\n{EMOJI_DIGITS['0']} — В главное меню", parse_mode="html") set_state(user_id, "LK_DOCS_SUCCESS") else: raise Exception("Внутренняя ошибка SMTP при отправке письма") 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) else: await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html") elif current_state == "LK_DOCS_SUCCESS": 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, "LK_DOCS_MENU"); await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html") else: await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")