""" Обработчики создания заявки. Поток: 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.flow_config import PAYLOADS, TRANSITIONS 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.texts import MenuTexts, TicketTexts 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 TicketTexts.ATT_PHOTO if AttachmentType.VIDEO.value in t: return TicketTexts.ATT_VIDEO if AttachmentType.AUDIO.value in t: dur = att.get("duration") if dur: mins, secs = divmod(int(dur), 60) return TicketTexts.ATT_AUDIO_DUR.format(mins=mins, secs=secs) return TicketTexts.ATT_AUDIO if AttachmentType.FILE.value in t: fname = att.get("filename") return TicketTexts.ATT_FILE.format(fname=fname) if fname else TicketTexts.ATT_FILE_UNNAMED return TicketTexts.ATT_OTHER def _preview_text(org_name: str | None, description: str, pending_atts: list[dict]) -> str: """Формирует текст предпросмотра заявки.""" task_name = org_name or description[:80] lines = [ TicketTexts.PREVIEW_HEADER, "", f"📄 {task_name}", "", description[:500] + ("…" if len(description) > 500 else ""), ] if pending_atts: lines += ["", TicketTexts.PREVIEW_ATTACHMENTS_HEADER] for att in pending_atts: lines.append(f" • {_att_label(att)}") lines += ["", TicketTexts.PREVIEW_CONFIRM_PROMPT] return "\n".join(lines) # ── Шаг 1: начало создания ──────────────────────────────────────────────────── @router.message_callback(F.callback.payload == PAYLOADS.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(TRANSITIONS["ticket:start"]) 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=TicketTexts.ENTER_DESCRIPTION, attachments=[done_ticket_kb()], ) await _track_mid(context, mid) # ── Шаг 2: первое сообщение с описанием ────────────────────────────────────── @router.message_created(states=TRANSITIONS["ticket:start"]) 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=TicketTexts.NEED_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=TicketTexts.FILE_RECEIVED_NEED_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(TRANSITIONS["ticket:description_ok"]) mid = await bot_send( event.bot, chat_id=chat_id, user_id=user_id, text=TicketTexts.DESCRIPTION_ACCEPTED, attachments=[done_ticket_kb()], ) await _track_mid(context, mid) # ── Шаг 3: дополнительные вложения/текст ───────────────────────────────────── @router.message_created(states=TRANSITIONS["ticket:description_ok"]) 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 == PAYLOADS.DONE_TICKET, states=[TRANSITIONS["ticket:start"], TRANSITIONS["ticket:description_ok"]], ) 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=TicketTexts.PREVIEW_NEED_TEXT_NOTIF) await bot_send( event.bot, chat_id=chat_id, user_id=bot_user["max_user_id"], text=TicketTexts.PREVIEW_NEED_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(TRANSITIONS["ticket:preview"]) 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 == PAYLOADS.EDIT_TICKET, states=TRANSITIONS["ticket:preview"], ) async def cb_edit_ticket( event: MessageCallback, context: BaseContext, bot_user: dict ) -> None: await event.answer(notification="") await context.set_state(TRANSITIONS["ticket:edit"]) 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=TicketTexts.CONTINUE_EDITING, attachments=[done_ticket_kb()], ) await _track_mid(context, mid) # ── Шаг 5в: "Отменить создание" ────────────────────────────────────────────── @router.message_callback( F.callback.payload == PAYLOADS.CANCEL_TICKET, states=[ TRANSITIONS["ticket:start"], TRANSITIONS["ticket:description_ok"], TRANSITIONS["ticket:preview"], ], ) async def cb_cancel_ticket( event: MessageCallback, context: BaseContext, bot_user: dict ) -> None: await event.answer(notification=TicketTexts.CANCEL_NOTIF) chat_id = event.message.recipient.chat_id if event.message else None if chat_id is None: return # Удаляем все сообщения потока + текущее сообщение с кнопками 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() loading_mid = await bot_send( event.bot, chat_id=chat_id, user_id=bot_user["max_user_id"], text=MenuTexts.LOADING, ) menu_text = await build_main_menu_text(bot_user) edited = False if loading_mid: try: await event.bot.edit_message( message_id=loading_mid, text=menu_text, attachments=[main_menu_kb()], ) edited = True await nav_set(context, loading_mid) except Exception: pass if not edited: mid = await bot_send( event.bot, chat_id=chat_id, user_id=bot_user["max_user_id"], text=menu_text, attachments=[main_menu_kb()], ) await nav_set(context, mid) # ── Шаг 5а: "Подтвердить" → создание ───────────────────────────────────────── @router.message_callback( F.callback.payload == PAYLOADS.CONFIRM_TICKET, states=TRANSITIONS["ticket:preview"], ) 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 loading_mid = await bot_send( event.bot, chat_id=chat_id, user_id=bot_user["max_user_id"], text=TicketTexts.SENDING, ) # ── Загружаем вложения в 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 = [TicketTexts.CREATED] if intradesk_number: lines.append(TicketTexts.CREATED_NUMBER.format(number=intradesk_number)) lines += ["", f"📄 {task_name}", ""] lines.append(description[:500] + ("…" if len(description) > 500 else "")) if pending_atts: lines += ["", TicketTexts.PREVIEW_ATTACHMENTS_HEADER] for att in pending_atts: lines.append(f" • {_att_label(att)}") lines += ["", TicketTexts.CREATED_CONTACT] final_text = "\n".join(lines) edited = False if loading_mid: try: await event.bot.edit_message( message_id=loading_mid, text=final_text, attachments=[main_menu_kb()], ) edited = True await nav_set(context, loading_mid) except Exception: pass if not edited: mid = await bot_send( event.bot, chat_id=chat_id, user_id=bot_user["max_user_id"], text=final_text, attachments=[main_menu_kb()], ) await nav_set(context, mid)