Обновить service_desk/handlers.py
This commit is contained in:
+84
-70
@@ -23,7 +23,7 @@ if not sd_workflow_logger.handlers:
|
|||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
sd_workflow_logger.addHandler(handler)
|
sd_workflow_logger.addHandler(handler)
|
||||||
# ----------------------------------
|
# ----------------------------------
|
||||||
|
|
||||||
# Абсолютный импорт конфигурации
|
# Абсолютный импорт конфигурации
|
||||||
spec = importlib.util.spec_from_file_location("custom_config", 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", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "config.py"))
|
||||||
custom_config = importlib.util.module_from_spec(spec)
|
custom_config = importlib.util.module_from_spec(spec)
|
||||||
@@ -44,14 +44,14 @@ from utils.texts import (
|
|||||||
sd_uploading_file,
|
sd_uploading_file,
|
||||||
sd_file_attached,
|
sd_file_attached,
|
||||||
sd_file_upload_error,
|
sd_file_upload_error,
|
||||||
|
system_error_text,
|
||||||
|
UNKNOWN_MAIN_CMD_TEXT,
|
||||||
)
|
)
|
||||||
from utils.texts import UNKNOWN_MAIN_CMD_TEXT
|
|
||||||
# 🔌 Импортируем централизованную функцию сбора статистики из main
|
|
||||||
from utils.stats_logger import log_menu_stats
|
from utils.stats_logger import log_menu_stats
|
||||||
|
|
||||||
# Таймаут ожидания вложений после текста (секунды)
|
# Таймаут ожидания вложений после текста (секунды)
|
||||||
ATTACHMENT_WAIT_TIMEOUT = 2.0
|
ATTACHMENT_WAIT_TIMEOUT = 2.0
|
||||||
|
|
||||||
SD_DISPATCHER_SYSTEM_PROMPT = (
|
SD_DISPATCHER_SYSTEM_PROMPT = (
|
||||||
"Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — сформулировать краткую и понятную тему ИТ-заявки по-русски (от 3 до 7 слов) на основе текста пользователя.\n"
|
"Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — сформулировать краткую и понятную тему ИТ-заявки по-русски (от 3 до 7 слов) на основе текста пользователя.\n"
|
||||||
"ПРАВИЛА:\n"
|
"ПРАВИЛА:\n"
|
||||||
@@ -96,14 +96,14 @@ async def generate_smart_subject(text: str) -> str:
|
|||||||
OLLAMA_SUBJECT_MODEL = custom_config.OLLAMA_SUBJECT_MODEL
|
OLLAMA_SUBJECT_MODEL = custom_config.OLLAMA_SUBJECT_MODEL
|
||||||
def has_chinese(t): return bool(re.search(r'[\u4e00-\u9fff]', str(t)))
|
def has_chinese(t): return bool(re.search(r'[\u4e00-\u9fff]', str(t)))
|
||||||
if not text or len(text.split()) < 2: return "Заявка из КЛЕВЕР"
|
if not text or len(text.split()) < 2: return "Заявка из КЛЕВЕР"
|
||||||
|
|
||||||
user_msg = f"Сформулируй краткую тему для следующей ИТ-заявки:\n{text}"
|
user_msg = f"Сформулируй краткую тему для следующей ИТ-заявки:\n{text}"
|
||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": SD_DISPATCHER_SYSTEM_PROMPT},
|
{"role": "system", "content": SD_DISPATCHER_SYSTEM_PROMPT},
|
||||||
{"role": "user", "content": user_msg}
|
{"role": "user", "content": user_msg}
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = {"model": OLLAMA_SUBJECT_MODEL, "messages": messages, "temperature": 0.0}
|
payload = {"model": OLLAMA_SUBJECT_MODEL, "messages": messages, "temperature": 0.0}
|
||||||
sd_workflow_logger.info(f"📝 [Subject Gen] Payload: {payload}")
|
sd_workflow_logger.info(f"📝 [Subject Gen] Payload: {payload}")
|
||||||
@@ -131,17 +131,26 @@ async def create_ticket_in_sd(requester_email: str, subject: str, description: s
|
|||||||
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"}
|
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"}
|
||||||
endpoint = f"{SD_URL}/api/v3/requests"
|
endpoint = f"{SD_URL}/api/v3/requests"
|
||||||
html_desc = f"<p>{description.replace(chr(10), '<br>')}</p><br><hr><p style='color:#555;font-size:12px;'><i>Создано через TrueConf КЛЕВЕР</i></p>"
|
html_desc = f"<p>{description.replace(chr(10), '<br>')}</p><br><hr><p style='color:#555;font-size:12px;'><i>Создано через TrueConf КЛЕВЕР</i></p>"
|
||||||
|
|
||||||
async def _send_req(email, current_city):
|
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}}}
|
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:
|
async with httpx.AsyncClient(verify=False) as client:
|
||||||
return await client.post(endpoint, headers=headers, data={"input_data": json.dumps(payload)}, timeout=15.0)
|
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 {}
|
# Безопасная обработка сбоев сети / отсуствия доступа к серверу SD
|
||||||
if data.get("response_status", {}).get("status_code") != 2000:
|
try:
|
||||||
resp = await _send_req(requester_email, "Кемерово")
|
resp = await _send_req(requester_email, city)
|
||||||
data = resp.json() if resp.status_code in [200, 201] else {}
|
data = resp.json() if resp.status_code in [200, 201] else {}
|
||||||
if data.get("response_status", {}).get("status_code") == 2000:
|
if data.get("response_status", {}).get("status_code") != 2000:
|
||||||
return data.get("request", {}).get("id")
|
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}")
|
||||||
|
return None
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def process_and_upload_file(file_id: str, filename: str, ticket_id: str):
|
async def process_and_upload_file(file_id: str, filename: str, ticket_id: str):
|
||||||
@@ -219,57 +228,64 @@ def _extract_attachments_from_content(content, is_attachment_type=False):
|
|||||||
async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login):
|
async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login):
|
||||||
"""Логика генерации темы, отправки в SD и загрузки всех очередей вложений."""
|
"""Логика генерации темы, отправки в SD и загрузки всех очередей вложений."""
|
||||||
session["step"] = "creating_ticket"
|
session["step"] = "creating_ticket"
|
||||||
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}")
|
|
||||||
sender_email = ad_user.get("mail") if ad_user else DEFAULT_REQUESTER
|
|
||||||
city = ad_user.get("city") if ad_user else "Кемерово"
|
|
||||||
subject = await generate_smart_subject(msg_text)
|
|
||||||
sd_workflow_logger.info(f"📝 [Subject Gen] Text: {msg_text[:50]}... -> Subject: {subject}")
|
|
||||||
|
|
||||||
ticket_id = await create_ticket_in_sd(sender_email, subject, msg_text, city)
|
try:
|
||||||
sd_workflow_logger.info(f"🎫 [Ticket Created] ID: {ticket_id}")
|
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}")
|
||||||
if ticket_id:
|
sender_email = ad_user.get("mail") if ad_user else DEFAULT_REQUESTER
|
||||||
session["ticket_id"] = ticket_id
|
city = ad_user.get("city") if ad_user else "Кемерово"
|
||||||
|
subject = await generate_smart_subject(msg_text)
|
||||||
|
sd_workflow_logger.info(f"📝 [Subject Gen] Text: {msg_text[:50]}... -> Subject: {subject}")
|
||||||
|
|
||||||
# 1. Загрузка основных вложений (накопленных до и во время таймаута)
|
ticket_id = await create_ticket_in_sd(sender_email, subject, msg_text, city)
|
||||||
queued_files = list(session.get("files_queue", []))
|
sd_workflow_logger.info(f"🎫 [Ticket Created] ID: {ticket_id}")
|
||||||
if queued_files:
|
|
||||||
sd_workflow_logger.info(f"📎 [Upload] Uploading {len(queued_files)} file(s) to ticket #{ticket_id}")
|
|
||||||
for f in queued_files:
|
|
||||||
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-запросов (из "черной дыры")
|
if ticket_id:
|
||||||
post_files = list(session.get("post_create_queue", []))
|
session["ticket_id"] = ticket_id
|
||||||
if post_files:
|
|
||||||
sd_workflow_logger.info(f"📎 [Upload Extra] Uploading {len(post_files)} late file(s) to ticket #{ticket_id}")
|
# 1. Загрузка основных вложений
|
||||||
for f in post_files:
|
queued_files = list(session.get("files_queue", []))
|
||||||
await process_and_upload_file(f['file_id'], f['file_name'], ticket_id)
|
if queued_files:
|
||||||
|
sd_workflow_logger.info(f"📎 [Upload] Uploading {len(queued_files)} file(s) to ticket #{ticket_id}")
|
||||||
session["files_queue"] = []
|
for f in queued_files:
|
||||||
session["post_create_queue"] = []
|
await process_and_upload_file(f['file_id'], f['file_name'], ticket_id)
|
||||||
session["step"] = "ticket_created"
|
sd_workflow_logger.info(f"✅ [Upload] All queued file(s) uploaded")
|
||||||
|
|
||||||
await msg.answer(
|
# 2. Загрузка вложений, прилетевших во время выполнения API-запросов
|
||||||
sd_ticket_created(ticket_id, subject, msg_text),
|
post_files = list(session.get("post_create_queue", []))
|
||||||
parse_mode="html"
|
if post_files:
|
||||||
)
|
sd_workflow_logger.info(f"📎 [Upload Extra] Uploading {len(post_files)} late file(s) to ticket #{ticket_id}")
|
||||||
log_menu_stats(user_id, "Service Desk", f"Создание заявки #{ticket_id}")
|
for f in post_files:
|
||||||
else:
|
await process_and_upload_file(f['file_id'], f['file_name'], ticket_id)
|
||||||
session["step"] = "need_text"
|
|
||||||
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
session["files_queue"] = []
|
||||||
|
session["post_create_queue"] = []
|
||||||
|
session["step"] = "ticket_created"
|
||||||
|
|
||||||
|
await msg.answer(
|
||||||
|
sd_ticket_created(ticket_id, subject, msg_text),
|
||||||
|
parse_mode="html"
|
||||||
|
)
|
||||||
|
log_menu_stats(user_id, "Service Desk", f"Создание заявки #{ticket_id}")
|
||||||
|
else:
|
||||||
|
session["step"] = "need_text"
|
||||||
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"❌ [SD Ticket Error] {user_id}: {e}")
|
||||||
|
sd_workflow_logger.error(f"❌ [SD Ticket Error] {user_id}: {e}")
|
||||||
|
session["step"] = "need_text"
|
||||||
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
|
|
||||||
async def _wait_for_attachments_and_create(user_id, session):
|
async def _wait_for_attachments_and_create(user_id, session):
|
||||||
"""Фоновый таймер с возможностью отмены (Debounce)."""
|
"""Фоновый таймер с возможностью отмены (Debounce)."""
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(ATTACHMENT_WAIT_TIMEOUT)
|
await asyncio.sleep(ATTACHMENT_WAIT_TIMEOUT)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return # Таймер сброшен, прерываем выполнение текущей таски
|
return
|
||||||
|
|
||||||
if session.get("step") != "waiting_for_attachments":
|
if session.get("step") != "waiting_for_attachments":
|
||||||
return
|
return
|
||||||
|
|
||||||
sd_workflow_logger.info(f"⏰ [SD] Timeout reached. Initiating ticket creation for user {user_id}")
|
sd_workflow_logger.info(f"⏰ [SD] Timeout reached. Initiating ticket creation for user {user_id}")
|
||||||
msg = session.get("msg")
|
msg = session.get("msg")
|
||||||
login = session.get("login")
|
login = session.get("login")
|
||||||
@@ -285,6 +301,16 @@ async def _wait_for_attachments_and_create(user_id, session):
|
|||||||
except Exception:
|
except Exception:
|
||||||
sd_workflow_logger.error(f"❌ [SD] Failed to send error message to {user_id}")
|
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}")
|
||||||
|
|
||||||
@router.message()
|
@router.message()
|
||||||
async def sd_module_handler(msg: Message):
|
async def sd_module_handler(msg: Message):
|
||||||
try:
|
try:
|
||||||
@@ -309,7 +335,7 @@ async def sd_module_handler(msg: Message):
|
|||||||
cmd = raw_num
|
cmd = raw_num
|
||||||
break
|
break
|
||||||
|
|
||||||
# --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню) ---
|
# --- ВЫХОД ---
|
||||||
if cmd in ["0", "9", "/start", "меню"]:
|
if cmd in ["0", "9", "/start", "меню"]:
|
||||||
log_menu_stats(user_id, "Service Desk", "Выход в главное меню")
|
log_menu_stats(user_id, "Service Desk", "Выход в главное меню")
|
||||||
states.clear_state(user_id)
|
states.clear_state(user_id)
|
||||||
@@ -327,29 +353,24 @@ async def sd_module_handler(msg: Message):
|
|||||||
await msg.answer(SD_UNKNOWN_CMD_TEXT, parse_mode="html")
|
await msg.answer(SD_UNKNOWN_CMD_TEXT, parse_mode="html")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Если только вложение без текста — сохраняем и ждем текст
|
|
||||||
if not msg_text.strip() and inline_attachments:
|
if not msg_text.strip() and inline_attachments:
|
||||||
session["files_queue"].extend(inline_attachments)
|
session["files_queue"].extend(inline_attachments)
|
||||||
sd_workflow_logger.info(f"📎 [SD] Saved {len(inline_attachments)} attachment(s), waiting for text")
|
sd_workflow_logger.info(f"📎 [SD] Saved {len(inline_attachments)} attachment(s), waiting for text")
|
||||||
await msg.answer(SD_TEXT_REQUIRED, parse_mode="html")
|
await msg.answer(SD_TEXT_REQUIRED, parse_mode="html")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Если пришел текст (с вложениями или без)
|
|
||||||
if inline_attachments:
|
if inline_attachments:
|
||||||
session["files_queue"].extend(inline_attachments)
|
session["files_queue"].extend(inline_attachments)
|
||||||
sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue")
|
sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue")
|
||||||
|
|
||||||
# Настраиваем параметры сессии для ожидания
|
|
||||||
session["step"] = "waiting_for_attachments"
|
session["step"] = "waiting_for_attachments"
|
||||||
session["msg_text"] = msg_text
|
session["msg_text"] = msg_text
|
||||||
session["msg"] = msg
|
session["msg"] = msg
|
||||||
session["user_id"] = user_id
|
session["user_id"] = user_id
|
||||||
session["login"] = login
|
session["login"] = login
|
||||||
|
|
||||||
# Сразу выдаем ОДНО сообщение пользователю, чтобы он видел реакцию бота
|
|
||||||
await msg.answer(SD_CREATING, parse_mode="html")
|
await msg.answer(SD_CREATING, parse_mode="html")
|
||||||
|
|
||||||
# Запускаем фоновый таймер с механизмом сброса (Debounce)
|
|
||||||
if "timer_task" in session and not session["timer_task"].done():
|
if "timer_task" in session and not session["timer_task"].done():
|
||||||
session["timer_task"].cancel()
|
session["timer_task"].cancel()
|
||||||
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
|
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
|
||||||
@@ -357,7 +378,7 @@ async def sd_module_handler(msg: Message):
|
|||||||
sd_workflow_logger.info(f"⏳ [SD] Timer started: waiting {ATTACHMENT_WAIT_TIMEOUT}s for potentially more attachments...")
|
sd_workflow_logger.info(f"⏳ [SD] Timer started: waiting {ATTACHMENT_WAIT_TIMEOUT}s for potentially more attachments...")
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- ШАГ 2: РЕЖИМ ОЖИДАНИЯ ДОП. ВЛОЖЕНИЙ (Сброс таймаута) ---
|
# --- ШАГ 2: РЕЖИМ ОЖИДАНИЯ ДОП. ВЛОЖЕНИЙ ---
|
||||||
if session["step"] == "waiting_for_attachments":
|
if session["step"] == "waiting_for_attachments":
|
||||||
if inline_attachments:
|
if inline_attachments:
|
||||||
session["files_queue"].extend(inline_attachments)
|
session["files_queue"].extend(inline_attachments)
|
||||||
@@ -366,7 +387,6 @@ async def sd_module_handler(msg: Message):
|
|||||||
session["msg_text"] = session["msg_text"] + " " + msg_text.strip()
|
session["msg_text"] = session["msg_text"] + " " + msg_text.strip()
|
||||||
sd_workflow_logger.info(f"📝 [SD] Additional text appended")
|
sd_workflow_logger.info(f"📝 [SD] Additional text appended")
|
||||||
|
|
||||||
# Перезапускаем таймер (пользователь активен, сдвигаем окно создания вперед)
|
|
||||||
if "timer_task" in session and not session["timer_task"].done():
|
if "timer_task" in session and not session["timer_task"].done():
|
||||||
session["timer_task"].cancel()
|
session["timer_task"].cancel()
|
||||||
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
|
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
|
||||||
@@ -374,7 +394,7 @@ async def sd_module_handler(msg: Message):
|
|||||||
sd_workflow_logger.info(f"🔄 [SD] Timer reset due to user activity. Waiting another {ATTACHMENT_WAIT_TIMEOUT}s")
|
sd_workflow_logger.info(f"🔄 [SD] Timer reset due to user activity. Waiting another {ATTACHMENT_WAIT_TIMEOUT}s")
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- ШАГ 3: ЗАЯВКА СОЗДАЕТСЯ (Перехват файлов из "черной дыры") ---
|
# --- ШАГ 3: ЗАЯВКА СОЗДАЕТСЯ ---
|
||||||
if session["step"] == "creating_ticket":
|
if session["step"] == "creating_ticket":
|
||||||
if inline_attachments:
|
if inline_attachments:
|
||||||
if "post_create_queue" not in session:
|
if "post_create_queue" not in session:
|
||||||
@@ -405,10 +425,4 @@ async def sd_module_handler(msg: Message):
|
|||||||
try:
|
try:
|
||||||
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user