intra_max_chatbot/bot/handlers/create_ticket.py

398 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Обработчики создания заявки.
Поток:
1. "Создать заявку" → waiting_description
2. Первое сообщение → сохранение описания, collecting_attachments
3. Доп. сообщения → накопление вложений
4. "Готово" → предпросмотр + кнопки подтверждения, confirming
5a. "Подтвердить" → сохранение в БД + Intradesk, сообщение с номером, главное меню
5b. "Продолжить редактирование" → обратно в collecting_attachments
5c. "Отменить создание" → очистка состояния, главное меню
"""
from maxapi.context import BaseContext
from maxapi.dispatcher import Router
from maxapi.enums.attachment import AttachmentType
from maxapi.filters import F
from maxapi.types.attachments.attachment import (
OtherAttachmentPayload,
PhotoAttachmentPayload,
)
from maxapi.types.updates.message_callback import MessageCallback
from maxapi.types.updates.message_created import MessageCreated
from bot.handlers.menu import build_main_menu_text
from bot.keyboards import confirm_ticket_kb, done_ticket_kb, main_menu_kb
from bot.logging_setup import get_logger
from bot.send_helper import bot_send
from bot.states import TicketStates
from database.connection import get_db
from database.queries.intradesk import get_intradesk_contact_id_for_phone
from database.queries.organizations import get_organization
from database.queries.ticket_attachments import add_attachment
from database.queries.tickets import create_ticket, set_intradesk_task_id
from external.intradesk_api import create_intradesk_task
logger = get_logger("bot.handlers.create_ticket")
router = Router(router_id="create_ticket")
_MEDIA_TYPES = {
AttachmentType.IMAGE,
AttachmentType.VIDEO,
AttachmentType.AUDIO,
AttachmentType.FILE,
}
def _extract_attachments(event: MessageCreated) -> list[dict]:
"""Извлекает метаданные вложений из сообщения."""
result = []
body = event.message.body
if not body or not body.attachments:
return result
for att in body.attachments:
att_type = getattr(att, "type", None)
if att_type not in _MEDIA_TYPES:
continue
url: str | None = None
filename: str | None = None
duration: int | None = None
payload = getattr(att, "payload", None)
if isinstance(payload, PhotoAttachmentPayload):
url = payload.url
elif isinstance(payload, OtherAttachmentPayload):
url = payload.url
filename = (
getattr(payload, "filename", None)
or getattr(payload, "name", None)
or getattr(payload, "title", None)
)
if att_type == AttachmentType.AUDIO:
duration = (
getattr(payload, "duration", None)
or getattr(payload, "duration_ms", None)
)
result.append({"type": str(att_type), "url": url, "filename": filename, "duration": duration})
return result
def _att_label(att: dict) -> str:
"""Человекочитаемое описание вложения для предпросмотра."""
t = att.get("type", "")
if AttachmentType.IMAGE.value in t or "PHOTO" in t.upper():
return "🖼 Фото"
if AttachmentType.VIDEO.value in t:
return "📹 Видео"
if AttachmentType.AUDIO.value in t:
dur = att.get("duration")
if dur:
mins, secs = divmod(int(dur), 60)
return f"🎤 Голосовое ({mins:02d}:{secs:02d})"
return "🎤 Голосовое"
if AttachmentType.FILE.value in t:
fname = att.get("filename")
return f"📎 {fname}" if fname else "📎 Файл"
return "📎 Вложение"
def _preview_text(org_name: str | None, description: str, pending_atts: list[dict]) -> str:
"""Формирует текст предпросмотра заявки."""
task_name = org_name or description[:80]
lines = [
"📋 Предпросмотр заявки:",
"",
f"📄 {task_name}",
"",
description[:500] + ("" if len(description) > 500 else ""),
]
if pending_atts:
lines += ["", "📎 Вложения:"]
for att in pending_atts:
lines.append(f"{_att_label(att)}")
lines += ["", "Всё верно? Подтвердите или вернитесь к редактированию."]
return "\n".join(lines)
# ── Шаг 1: начало создания ────────────────────────────────────────────────────
@router.message_callback(F.callback.payload == "create_ticket")
async def cb_create_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
await context.set_state(TicketStates.waiting_description)
await context.set_data({"pending_attachments": []})
logger.info("📝 create_ticket started | user_id=%s", bot_user["max_user_id"])
await event.answer(notification="")
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=(
"📝 Опишите вашу проблему.\n\n"
"Вы также можете прикрепить файлы, фото или голосовые сообщения.\n"
"Когда закончите — нажмите кнопку «Готово»."
),
attachments=[done_ticket_kb()],
)
# ── Шаг 2: первое сообщение с описанием ──────────────────────────────────────
@router.message_created(states=TicketStates.waiting_description)
async def on_ticket_description(
event: MessageCreated, context: BaseContext
) -> None:
chat_id, user_id = event.get_ids()
if chat_id is None:
return
body = event.message.body
text = (body.text or "").strip() if body else ""
attachments = _extract_attachments(event)
await context.update_data(description=text, pending_attachments=attachments)
await context.set_state(TicketStates.collecting_attachments)
await bot_send(
event.bot,
chat_id=chat_id,
user_id=user_id,
text=(
"✏️ Описание принято. Вы можете добавить ещё файлы "
"или нажмите «Готово» для отправки заявки."
),
attachments=[done_ticket_kb()],
)
# ── Шаг 3: дополнительные вложения/текст ─────────────────────────────────────
@router.message_created(states=TicketStates.collecting_attachments)
async def on_ticket_attachment(
event: MessageCreated, context: BaseContext
) -> None:
data = await context.get_data()
body = event.message.body
extra_text = (body.text or "").strip() if body else ""
new_atts = _extract_attachments(event)
if extra_text:
current = data.get("description", "")
data["description"] = (current + "\n" + extra_text).strip()
pending = data.get("pending_attachments", [])
pending.extend(new_atts)
data["pending_attachments"] = pending
await context.set_data(data)
# ── Шаг 4: "Готово" → предпросмотр ───────────────────────────────────────────
@router.message_callback(
F.callback.payload == "done_ticket",
states=[TicketStates.waiting_description, TicketStates.collecting_attachments],
)
async def cb_done_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
data = await context.get_data()
description = (data.get("description") or "").strip()
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
if not description:
await event.answer(notification="Сначала опишите проблему!")
await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text="❗ Пожалуйста, сначала напишите описание проблемы.",
attachments=[done_ticket_kb()],
)
return
await event.answer(notification="")
pending_atts = data.get("pending_attachments", [])
org = await get_organization(bot_user["org_id"])
org_name = org["name"] if org else None
await context.set_state(TicketStates.confirming)
await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=_preview_text(org_name, description, pending_atts),
attachments=[confirm_ticket_kb()],
)
# ── Шаг 5б: "Продолжить редактирование" ──────────────────────────────────────
@router.message_callback(
F.callback.payload == "edit_ticket",
states=TicketStates.confirming,
)
async def cb_edit_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
await event.answer(notification="")
await context.set_state(TicketStates.collecting_attachments)
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=(
"✏️ Продолжайте добавлять описание или вложения.\n"
"Нажмите «Готово» когда будете готовы."
),
attachments=[done_ticket_kb()],
)
# ── Шаг 5в: "Отменить создание" ──────────────────────────────────────────────
@router.message_callback(
F.callback.payload == "cancel_ticket",
states=TicketStates.confirming,
)
async def cb_cancel_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
await event.answer(notification="Создание отменено")
await context.clear()
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=await build_main_menu_text(bot_user),
attachments=[main_menu_kb()],
)
# ── Шаг 5а: "Подтвердить" → создание ─────────────────────────────────────────
@router.message_callback(
F.callback.payload == "confirm_ticket",
states=TicketStates.confirming,
)
async def cb_confirm_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
# Защита от двойного нажатия: читаем данные и сбрасываем состояние атомарно.
# Повторный tap уже не пройдёт фильтр states=confirming.
data = await context.get_data()
await context.clear()
description = (data.get("description") or "").strip()
pending_atts = data.get("pending_attachments", [])
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
await event.answer(notification="Отправляем заявку…")
# ── Данные организации ────────────────────────────────────────────────────
org = await get_organization(bot_user["org_id"])
org_name = org["name"] if org else None
intradesk_id: int | None = org.get("intradesk_id") if org else None
contact_person_id: int | None = None
if intradesk_id and bot_user.get("phone"):
db = get_db()
contact_person_id = await get_intradesk_contact_id_for_phone(
db, intradesk_id, bot_user["phone"]
)
# ── Сохраняем локально ────────────────────────────────────────────────────
ticket_id = await create_ticket(
org_id=bot_user["org_id"],
created_by=bot_user["id"],
description=description,
)
for att in pending_atts:
await add_attachment(
ticket_id=ticket_id,
attachment_type=att["type"],
attachment_url=att.get("url"),
)
logger.info(
"✅ ticket created | ticket_id=%s user_id=%s org_id=%s attachments=%s",
ticket_id, bot_user["max_user_id"], bot_user["org_id"], len(pending_atts),
)
# ── Отправка в Intradesk (ждём, чтобы получить номер) ────────────────────
task_name = org_name or description[:150]
att_urls = [a["url"] for a in pending_atts if a.get("url")]
intradesk_result = await create_intradesk_task(
name=task_name,
description=description,
client_intradesk_id=intradesk_id,
contact_person_id=contact_person_id,
attachment_urls=att_urls or None,
)
intradesk_number: int | None = None
if intradesk_result:
iid = (
intradesk_result.get("id") or intradesk_result.get("Id")
or intradesk_result.get("ID")
)
intradesk_number = (
intradesk_result.get("tasknumber") or intradesk_result.get("number")
or intradesk_result.get("Number") or intradesk_result.get("TaskNumber")
)
if iid:
await set_intradesk_task_id(ticket_id, int(iid))
logger.info(
"Intradesk task created: id=%s number=%s for ticket_id=%s",
iid, intradesk_number, ticket_id,
)
# ── Итоговое сообщение ────────────────────────────────────────────────────
lines = ["✅ Заявка успешно создана!"]
if intradesk_number:
lines.append(f"Номер заявки: #{intradesk_number}")
lines += ["", f"📄 {task_name}", ""]
lines.append(description[:500] + ("" if len(description) > 500 else ""))
if pending_atts:
lines += ["", "📎 Вложения:"]
for att in pending_atts:
lines.append(f"{_att_label(att)}")
lines += ["", "Наши специалисты свяжутся с вами в ближайшее время."]
await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text="\n".join(lines),
)
await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=await build_main_menu_text(bot_user),
attachments=[main_menu_kb()],
)