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 = system_error_text(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}")
await handle_lk_error(msg, user_id, "Сборка заголовка ЛК", str(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):
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: "Декабрь"
}
now = datetime.now()
# Генерируем список из 4 месяцев: текущий, прошлый, позапрошлый, за два месяца
candidates = []
m = now
for _ in range(4):
candidates.append((m.year, m.month))
m = m.replace(day=1) - timedelta(days=1)
# Проверяем какие месяцы имеют данные в БД
import lk.info_service as info_service
conn = info_service.get_db_connection()
cursor = conn.cursor()
try:
available = []
for year, month in candidates:
cursor.execute(
"SELECT TOP 1 1 FROM UOV_SELFSERVICE_TB_TABEL WHERE EMP_ID IN (SELECT ID FROM UOV_SELFSERVICE_PR_EMP WHERE CARD_ID=%s) AND YEAR(D)=%s AND MONTH(D)=%s",
(card_id, year, month)
)
if cursor.fetchone():
available.append((year, month))
finally:
conn.close()
if not available:
await msg.answer("⚠️ Данные табеля отсутствуют.\n\nЗа указанный период данные в системе не найдены.", parse_mode="html")
return
# Строим динамическое меню
lines = ["⏱ Выберите месяц для просмотра табеля:\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{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню")
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")
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.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)
tabel_report = await asyncio.to_thread(
get_tabel_report_text,
card_id,
target_year,
target_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:
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
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:
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")
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")