""" Обработчики создания заявки. Поток: 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.nav_helper import nav_set 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, upload_attachments 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 "" new_atts = _extract_attachments(event) if not text: if not new_atts: # Стикер/локация/etc — просим написать описание mid = await bot_send( event.bot, chat_id=chat_id, user_id=user_id, text="❗ Пожалуйста, напишите описание проблемы текстом.", attachments=[done_ticket_kb()], ) await _track_mid(context, mid) return # Файл без текста — сохраняем, остаёмся в waiting_description, просим текст data = await context.get_data() existing = data.get("pending_attachments") or [] existing.extend(new_atts) await context.update_data(pending_attachments=existing) mid = await bot_send( event.bot, chat_id=chat_id, user_id=user_id, text="✅ Вложение принято! Теперь напишите описание проблемы текстом:", attachments=[done_ticket_kb()], ) await _track_mid(context, mid) return # Есть текст — объединяем с ранее добавленными вложениями data = await context.get_data() existing_atts = data.get("pending_attachments") or [] existing_atts.extend(new_atts) await context.update_data(description=text, pending_attachments=existing_atts) 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.waiting_description, TicketStates.collecting_attachments, 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 menu_mid = 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()], ) await nav_set(context, menu_mid) # ── Шаг 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.message.answer("⏳ Отправляем заявку…") # ── Загружаем вложения в Intradesk ──────────────────────────────────────── if pending_atts: pending_atts = await upload_attachments(pending_atts, task_id=0, target="Description") # ── Данные организации ──────────────────────────────────────────────────── 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] intradesk_result = await create_intradesk_task( name=task_name, description=description, client_intradesk_id=intradesk_id, contact_person_id=contact_person_id, attachments=pending_atts 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), ) menu_mid = 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()], ) await nav_set(context, menu_mid)