Files
trueconf_bot/instruct/handlers.py
T
2026-07-30 14:22:46 +07:00

502 lines
22 KiB
Python

import logging
import asyncio
import os
import sys
import json
import httpx
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from trueconf import Router, Message
from trueconf.types import FSInputFile
from utils.texts import (
UNKNOWN_MAIN_CMD_TEXT,
EMOJI_DIGITS,
INSTRUCT_MAIN_MENU_TEXT,
INSTRUCT_TRUECONF_TEXT,
INSTRUCT_TRUECONF_WITH_AD,
INSTRUCT_TRUECONF_PENDING_TEXT,
INSTRUCT_EMAIL_AVAILABLE,
INSTRUCT_EMAIL_UNAVAILABLE,
INSTRUCT_ERROR_MSG,
)
from utils.states import get_state, set_state, clear_state
from utils.stats_logger import log_menu_stats
# Безопасный импорт конфигурации
try:
from config.config import (
DEFAULT_REQUESTER, SD_URL, SD_TOKEN,
SD_INSTRUCT_GROUP_NAME, SD_INSTRUCT_TECHNICIAN_NAME,
SMTP_SERVER, SMTP_PORT, DEFAULT_EMAIL_FROM, ALERTS_SUPPORT_EMAILS
)
except ImportError:
import importlib.util
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "config.py")
spec = importlib.util.spec_from_file_location("custom_config", config_path)
custom_config = importlib.util.module_from_spec(spec)
spec.loader.exec_module(custom_config)
DEFAULT_REQUESTER = custom_config.DEFAULT_REQUESTER
SD_URL = custom_config.SD_URL
SD_TOKEN = custom_config.SD_TOKEN
SD_INSTRUCT_GROUP_NAME = custom_config.SD_INSTRUCT_GROUP_NAME
SD_INSTRUCT_TECHNICIAN_NAME = custom_config.SD_INSTRUCT_TECHNICIAN_NAME
SMTP_SERVER = getattr(custom_config, "SMTP_SERVER", "srvkem-mail.sibcem.ru")
SMTP_PORT = getattr(custom_config, "SMTP_PORT", 25)
DEFAULT_EMAIL_FROM = getattr(custom_config, "DEFAULT_EMAIL_FROM", "ai@sibcem.ru")
ALERTS_SUPPORT_EMAILS = getattr(custom_config, "ALERTS_SUPPORT_EMAILS", ["ds.krivochenko@sibcem.ru"])
logger = logging.getLogger(__name__)
router = Router()
TRUECONF_INSTRUCT_PATH = os.path.join(
os.path.dirname(__file__),
"Проверка_наличия_и_авторизация_на_мобильном_устройстве_Trueconf.docx"
)
GMAIL_ANDROID_INSTRUCT_PATH = os.path.join(
os.path.dirname(__file__),
"Инструкция по настройке электронной почты GMAIL на Android.docx"
)
APPLE_INSTRUCT_PATH = os.path.join(
os.path.dirname(__file__),
"Инструкция по настройке электронной почты на устройствах Apple.docx"
)
# ВСПОМОГАТЕЛЬНАЯ ФУНКЦИЯ ОТПРАВКИ EMAIL УВЕДОМЛЕНИЙ
def _send_email_sync(to_emails: list[str], subject: str, body_html: str):
"""Синхронная отправка письма по SMTP"""
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = DEFAULT_EMAIL_FROM
msg["To"] = ", ".join(to_emails)
part_html = MIMEText(body_html, "html", "utf-8")
msg.attach(part_html)
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=10) as server:
server.sendmail(DEFAULT_EMAIL_FROM, to_emails, msg.as_string())
logger.info(f"📧 Уведомление о заявке 2FA успешно отправлено на {to_emails}")
except Exception as e:
logger.error(f"❌ Ошибка отправки почтового уведомления 2FA: {e}", exc_info=True)
async def send_2fa_alert_email(cn: str, user_id: str, ticket_id: str):
"""Фоновая асинхронная задача формирования и отправки уведомления"""
if not ALERTS_SUPPORT_EMAILS:
logger.warning("ALERTS_SUPPORT_EMAILS пуст. Уведомление на почту пропущено.")
return
clean_user_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
subject = f"🎫 [2FA Request] Создана заявка #{ticket_id} на доступ к Trueconf ({cn})"
ticket_link = f"{SD_URL.rstrip('/')}/WorkOrder.do?woMode=viewWO&woID={ticket_id}"
body_html = f"""
<html>
<body style="font-family: Arial, sans-serif; color: #333;">
<h2>🎫 Создана новая заявка на доступ к Trueconf (2FA)</h2>
<p><b>Сотрудник:</b> {cn}</p>
<p><b>Логин / Email:</b> {clean_user_id}</p>
<p><b>Номер заявки в ServiceDesk:</b> <a href="{ticket_link}">#{ticket_id}</a></p>
<p><b>Назначенная группа:</b> {SD_INSTRUCT_GROUP_NAME}</p>
<p><b>Исполнитель:</b> {SD_INSTRUCT_TECHNICIAN_NAME}</p>
<hr style="border: 0; border-top: 1px solid #ccc;">
<p style="font-size: 12px; color: #777;"><i>Сообщение сгенерировано автоматически цифровым ассистентом КЛЕВЕР.</i></p>
</body>
</html>
"""
# Запускаем отправку в отдельном потоке, чтобы не блокировать event loop asyncio
await asyncio.to_thread(_send_email_sync, ALERTS_SUPPORT_EMAILS, subject, body_html)
# ОСНОВНАЯ ЛОГИКА АКТИВНОСТИ
def get_user_cn(user_id: str) -> str | None:
"""Получить CN пользователя из AD по его user_id."""
from utils.ad_search import search_by_user_id
try:
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
short_username = search_id.split("@")[0] if "@" in search_id else user_id
logger.info(f"AD lookup CN: user_id={user_id}, search_id={search_id}, short={short_username}")
entries = search_by_user_id(search_id, ["cn", "sAMAccountName", "mail", "userPrincipalName", "l", "userAccountControl"])
if entries:
entry = entries[0]
cn = str(entry.cn)
mail = str(entry.mail) if 'mail' in entry else ""
sam = str(entry.sAMAccountName) if 'sAMAccountName' in entry else "N/A"
upn = str(entry.userPrincipalName) if 'userPrincipalName' in entry else "N/A"
logger.info(f"AD found: cn={cn}, mail={mail}, sam={sam}, upn={upn}")
return cn
logger.warning(f"AD not found for user_id={user_id}, search_id={search_id}, short={short_username}")
except Exception as e:
logger.error(f"Ошибка получения CN из AD для {user_id}: {e}", exc_info=True)
return None
def get_ad_user_info(user_id: str) -> dict | None:
"""Получить полную информацию о пользователе из AD."""
from utils.ad_search import search_by_user_id
try:
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
entries = search_by_user_id(search_id, ["cn", "mail", "l", "userAccountControl", "userPrincipalName"])
if entries:
entry = entries[0]
cn = str(entry.cn)
raw_mail = str(entry.mail) if 'mail' in entry else ""
if raw_mail in ("[]", "N/A", ""):
raw_mail = None
mail = raw_mail if raw_mail else None
if not mail:
raw_upn = str(entry.userPrincipalName) if 'userPrincipalName' in entry else ""
if raw_upn and raw_upn not in ("[]", "N/A", ""):
mail = raw_upn
city = str(entry.l) if 'l' in entry and entry.l.value else "Кемерово"
return {"cn": cn, "mail": mail, "raw_mail": raw_mail, "city": city}
except Exception as e:
logger.error(f"Ошибка получения AD info для {user_id}: {e}", exc_info=True)
return None
async def _send_sd_api_request(
requester_cn: str,
requester_email: str,
subject: str,
description: str,
city: str
) -> str | None:
"""Автономная прямая отправка HTTP-запроса в Service Desk API v3."""
if requester_cn and requester_cn != "N/A":
requester_payload = {"name": requester_cn}
elif requester_email and requester_email not in (DEFAULT_REQUESTER, "ai@sibcem.ru"):
requester_payload = {"email_id": requester_email}
else:
requester_payload = {"name": "Искусственный Интеллект"}
payload = {
"request": {
"subject": subject,
"description": description,
"requester": requester_payload,
"udf_fields": {
"udf_pick_301": city
}
}
}
if SD_INSTRUCT_GROUP_NAME:
payload["request"]["group"] = {"name": SD_INSTRUCT_GROUP_NAME}
if SD_INSTRUCT_TECHNICIAN_NAME:
payload["request"]["technician"] = {"name": SD_INSTRUCT_TECHNICIAN_NAME}
url = f"{SD_URL.rstrip('/')}/api/v3/requests"
headers = {"TECHNICIAN_KEY": SD_TOKEN}
data = {"input_data": json.dumps(payload)}
async with httpx.AsyncClient(verify=False) as client:
response = await client.post(url, headers=headers, data=data, timeout=15.0)
if response.status_code in (200, 201):
res_json = response.json()
ticket_id = res_json.get("request", {}).get("id")
return str(ticket_id) if ticket_id else None
elif response.status_code == 400 and requester_payload != {"name": "Искусственный Интеллект"}:
logger.warning(
f"ServiceDesk не смог привязать requester={requester_payload}. "
f"Повторяем запрос от имени системного заявителя..."
)
payload["request"]["requester"] = {"name": "Искусственный Интеллект"}
payload["request"]["description"] = f"[Пользователь: {requester_cn}]\n\n{description}"
data_fb = {"input_data": json.dumps(payload)}
resp_fb = await client.post(url, headers=headers, data=data_fb, timeout=15.0)
if resp_fb.status_code in (200, 201):
ticket_id = resp_fb.json().get("request", {}).get("id")
return str(ticket_id) if ticket_id else None
logger.error(f"Service Desk API Error ({response.status_code}): {response.text}")
return None
async def _find_existing_trueconf_ticket(cn: str, requester_email: str = None) -> str | None:
"""Находит открытую или находящуюся в работе заявку по Trueconf."""
subject_keyword = "запрос доступа к trueconf"
CLOSED_STATUSES = {
"resolved", "rejected", "closed", "solved", "canceled", "cancelled",
"решена", "закрыта", "отклонена", "выполнена", "отменена", "закрыто", "решено"
}
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_status = req.get("status", {})
status_name = (
req_status.get("name", "") if isinstance(req_status, dict) else str(req_status)
).strip().lower()
req_id = req.get("id")
if status_name in CLOSED_STATUSES:
logger.info(f"Skipping closed ticket #{req_id} (status: '{status_name}')")
continue
logger.info(f"Checking candidate open ticket #{req_id} (status: '{status_name}')")
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", {})
requester = detail.get("requester", {})
req_name = str(requester.get("name", "") or requester.get("display_name", "")).strip().lower()
req_email = str(requester.get("email", "") or requester.get("email_id", "")).strip().lower()
req_desc = str(detail.get("description", "") or "").lower()
cn_clean = cn.strip().lower()
if cn_clean and cn_clean in req_name:
logger.info(f"Matched ticket #{req_id} by requester name ('{req_name}')")
return str(req_id)
if (
requester_email
and requester_email.lower() not in (DEFAULT_REQUESTER.lower(), "ai@sibcem.ru")
and req_email == requester_email.lower()
):
logger.info(f"Matched ticket #{req_id} by email ('{req_email}')")
return str(req_id)
if cn_clean and (f"[пользователь: {cn_clean}]" in req_desc or cn_clean in req_desc):
logger.info(f"Matched ticket #{req_id} by user mention in description")
return str(req_id)
else:
logger.error(f"Failed to fetch SD requests list completely, HTTP {resp.status_code}: {resp.text[:200]}")
except Exception as e:
logger.warning(f"Failed to search existing tickets: {e}", exc_info=True)
return None
async def create_sd_ticket_for_trueconf(user_id: str, cn: str) -> str | None:
"""Создать заявку в Service Desk для запроса доступа к Trueconf."""
subject = "Запрос доступа к Trueconf"
clean_user_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
ad_info = get_ad_user_info(user_id)
requester_email = (ad_info.get("mail") if ad_info else None) or DEFAULT_REQUESTER
city = ad_info.get("city", "Кемерово") if ad_info else "Кемерово"
# Защита от дублей
existing_ticket = await _find_existing_trueconf_ticket(cn, requester_email)
if existing_ticket:
logger.info(f"Ticket creation skipped: open ticket #{existing_ticket} already exists for {cn}")
return existing_ticket
description = (
f"Пользователь {cn} ({clean_user_id}) запросил инструкцию по Trueconf, "
f"но не имеет группы 2FA. Требуется выдача доступа."
)
try:
logger.info(f"Calling SD API directly: cn='{cn}', email='{requester_email}', city='{city}'")
ticket_id = await _send_sd_api_request(cn, requester_email, subject, description, city)
if ticket_id:
logger.info(f"SD ticket created for {cn}: #{ticket_id}")
# 📧 ОТПРАВЛЯЕМ ПОЧТОВОЕ УВЕДОМЛЕНИЕ В ФОНЕ
asyncio.create_task(send_2fa_alert_email(cn, user_id, ticket_id))
else:
logger.error(f"Failed to create SD ticket for {cn}")
return ticket_id
except Exception as e:
logger.error(f"Ошибка создания SD заявки для {cn}: {e}", exc_info=True)
return None
@router.message()
async def instruct_router_handler(msg: Message):
user_id = msg.from_user.id
user_name = getattr(msg.from_user, 'name', 'N/A')
user_username = getattr(msg.from_user, 'username', 'N/A')
logger.info(f"Instruct: user_id={user_id}, name={user_name}, username={user_username}")
current_state = get_state(user_id)
if not msg.text:
return
cmd = msg.text.strip().lower()
for raw_num, emoji_num in EMOJI_DIGITS.items():
if cmd == emoji_num:
cmd = raw_num
break
if current_state is None:
from utils.menu import MENU_MAP
if MENU_MAP.get(cmd) == "INSTRUCT":
msg.handled = True
set_state(user_id, "INSTRUCT_MODE")
await msg.answer(INSTRUCT_MAIN_MENU_TEXT, parse_mode="html")
return
return
if current_state == "INSTRUCT_MODE":
msg.handled = True
if cmd == "0":
log_menu_stats(user_id, "Инструкции", "Выход в главное меню")
clear_state(user_id)
from utils.menu import MENU_TEXT
await msg.answer(MENU_TEXT, parse_mode="html")
elif cmd == "1":
log_menu_stats(user_id, "Инструкции", "Trueconf")
cn = get_user_cn(user_id)
if cn is None:
cn = user_id
from utils.ad_checker import check_group_membership
ad_check = check_group_membership(cn, "2FA")
if ad_check.get("in_group", False):
set_state(user_id, "INSTRUCT_TRUECONF_VIEW")
text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn, None)
if os.path.isfile(TRUECONF_INSTRUCT_PATH):
await msg.bot.send_document(
chat_id=msg.chat_id,
file=FSInputFile(TRUECONF_INSTRUCT_PATH, file_name="Инструкция.docx"),
caption=text,
parse_mode="html"
)
else:
await msg.answer(text, parse_mode="html")
else:
ad_info = get_ad_user_info(user_id)
requester_email = (ad_info.get("mail") if ad_info else None) or DEFAULT_REQUESTER
logger.info(f"[DEBUG] Checking existing ticket for cn={cn}, email={requester_email}")
existing_ticket = await _find_existing_trueconf_ticket(cn, requester_email)
logger.info(f"[DEBUG] existing_ticket result: {existing_ticket}")
if existing_ticket:
set_state(user_id, "INSTRUCT_TRUECONF_VIEW")
text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn, existing_ticket)
if os.path.isfile(TRUECONF_INSTRUCT_PATH):
await msg.bot.send_document(
chat_id=msg.chat_id,
file=FSInputFile(TRUECONF_INSTRUCT_PATH, file_name="Инструкция.docx"),
caption=text,
parse_mode="html"
)
else:
await msg.answer(text, parse_mode="html")
else:
set_state(user_id, "INSTRUCT_TRUECONF_PENDING")
text = INSTRUCT_TRUECONF_PENDING_TEXT(EMOJI_DIGITS, cn)
await msg.answer(text, parse_mode="html")
elif cmd == "2":
log_menu_stats(user_id, "Инструкции", "Почта")
cn = get_user_cn(user_id)
if cn is None:
cn = user_id
ad_info = get_ad_user_info(user_id)
raw_mail = ad_info.get("raw_mail") if ad_info else None
if raw_mail:
text = INSTRUCT_EMAIL_AVAILABLE(EMOJI_DIGITS, cn, raw_mail)
# Отправляем текст один раз
await msg.answer(text, parse_mode="html")
# Затем отправляем обе инструкции
for instruct_path, file_name in [
(GMAIL_ANDROID_INSTRUCT_PATH, "Инструкция Android"),
(APPLE_INSTRUCT_PATH, "Инструкция Apple"),
]:
if os.path.isfile(instruct_path):
await msg.bot.send_document(
chat_id=msg.chat_id,
file=FSInputFile(instruct_path, file_name=file_name),
)
else:
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 in ["INSTRUCT_TRUECONF_VIEW", "INSTRUCT_EMAIL_VIEW"]:
msg.handled = True
if cmd == "0":
clear_state(user_id)
from utils.menu import MENU_TEXT
await msg.answer(MENU_TEXT, parse_mode="html")
elif cmd == "9":
set_state(user_id, "INSTRUCT_MODE")
await msg.answer(INSTRUCT_MAIN_MENU_TEXT, parse_mode="html")
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")