463 lines
18 KiB
Python
463 lines
18 KiB
Python
"""
|
||
Обработчики создания заявки.
|
||
|
||
Поток:
|
||
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, invalidate_client_cache
|
||
|
||
logger = get_logger("bot.handlers.create_ticket")
|
||
|
||
router = Router(router_id="create_ticket")
|
||
|
||
|
||
async def cleanup_ticket_flow(bot, context: BaseContext) -> None:
|
||
"""
|
||
Удалить висящие сообщения незавершённого потока создания заявки.
|
||
Вызывается из любого обработчика, который прерывает этот поток
|
||
(главное меню, список заявок, повторное нажатие «Создать заявку»).
|
||
"""
|
||
from bot.states import TicketStates, CommentStates # avoid circular at module level
|
||
state = await context.get_state()
|
||
creation_states = [
|
||
TicketStates.waiting_description,
|
||
TicketStates.collecting_attachments,
|
||
TicketStates.confirming,
|
||
CommentStates.waiting_text,
|
||
]
|
||
if state not in creation_states:
|
||
return
|
||
data = await context.get_data()
|
||
for mid in (data.get("flow_message_ids") or []):
|
||
try:
|
||
await bot.delete_message(message_id=mid)
|
||
except Exception:
|
||
pass
|
||
await context.clear()
|
||
|
||
|
||
async def _track_mid(context: BaseContext, mid: str | None) -> None:
|
||
"""Добавить message_id в список сообщений потока создания."""
|
||
if not mid:
|
||
return
|
||
data = await context.get_data()
|
||
ids: list[str] = data.get("flow_message_ids") or []
|
||
ids.append(mid)
|
||
data["flow_message_ids"] = ids
|
||
await context.set_data(data)
|
||
|
||
|
||
async def _delete_flow_messages(bot, context: BaseContext, extra_mid: str | None = None) -> None:
|
||
"""Удалить все сообщения, накопленные за время потока создания."""
|
||
data = await context.get_data()
|
||
ids: list[str] = list(data.get("flow_message_ids") or [])
|
||
if extra_mid:
|
||
ids.append(extra_mid)
|
||
for mid in ids:
|
||
try:
|
||
await bot.delete_message(message_id=mid)
|
||
except Exception:
|
||
pass # сообщение уже удалено или слишком старое
|
||
|
||
_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 cleanup_ticket_flow(event.bot, context)
|
||
|
||
await context.set_state(TicketStates.waiting_description)
|
||
await context.set_data({"pending_attachments": [], "flow_message_ids": []})
|
||
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
|
||
|
||
mid = await bot_send(
|
||
event.bot,
|
||
chat_id=chat_id,
|
||
user_id=bot_user["max_user_id"],
|
||
text=(
|
||
"📝 Опишите вашу проблему.\n\n"
|
||
"Вы также можете прикрепить файлы, фото или голосовые сообщения.\n"
|
||
"Когда закончите — нажмите кнопку «Готово»."
|
||
),
|
||
attachments=[done_ticket_kb()],
|
||
)
|
||
await _track_mid(context, mid)
|
||
|
||
|
||
# ── Шаг 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)
|
||
|
||
mid = await bot_send(
|
||
event.bot,
|
||
chat_id=chat_id,
|
||
user_id=user_id,
|
||
text=(
|
||
"✏️ Описание принято. Вы можете добавить ещё файлы "
|
||
"или нажмите «Готово» для отправки заявки."
|
||
),
|
||
attachments=[done_ticket_kb()],
|
||
)
|
||
await _track_mid(context, mid)
|
||
|
||
|
||
# ── Шаг 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)
|
||
|
||
mid = 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()],
|
||
)
|
||
await _track_mid(context, mid)
|
||
|
||
|
||
# ── Шаг 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
|
||
|
||
mid = await bot_send(
|
||
event.bot,
|
||
chat_id=chat_id,
|
||
user_id=bot_user["max_user_id"],
|
||
text=(
|
||
"✏️ Продолжайте добавлять описание или вложения.\n"
|
||
"Нажмите «Готово» когда будете готовы."
|
||
),
|
||
attachments=[done_ticket_kb()],
|
||
)
|
||
await _track_mid(context, mid)
|
||
|
||
|
||
# ── Шаг 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="Создание отменено")
|
||
|
||
# Удаляем все сообщения потока + текущее сообщение с кнопками
|
||
callback_mid = event.message.body.mid if (event.message and event.message.body) else None
|
||
await _delete_flow_messages(event.bot, context, extra_mid=callback_mid)
|
||
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()
|
||
# Удаляем все сообщения потока + текущее (предпросмотр с кнопками)
|
||
callback_mid = event.message.body.mid if (event.message and event.message.body) else None
|
||
await _delete_flow_messages(event.bot, context, extra_mid=callback_mid)
|
||
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,
|
||
)
|
||
if intradesk_id:
|
||
invalidate_client_cache(intradesk_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()],
|
||
)
|