diff --git a/bot/handlers/create_ticket.py b/bot/handlers/create_ticket.py index 6fc1c9c..a41825c 100644 --- a/bot/handlers/create_ticket.py +++ b/bot/handlers/create_ticket.py @@ -2,13 +2,14 @@ Обработчики создания заявки. Поток: -1. Нажатие "Создать заявку" → переход в waiting_description -2. Первое сообщение с текстом → сохранение описания, переход в collecting_attachments -3. Дополнительные сообщения → накопление вложений -4. Нажатие "Готово" → сохранение заявки в БД, подтверждение +1. "Создать заявку" → waiting_description +2. Первое сообщение → сохранение описания, collecting_attachments +3. Доп. сообщения → накопление вложений +4. "Готово" → предпросмотр + кнопки подтверждения, confirming +5a. "Подтвердить" → сохранение в БД + Intradesk, сообщение с номером, главное меню +5b. "Продолжить редактирование" → обратно в collecting_attachments +5c. "Отменить создание" → очистка состояния, главное меню """ -import asyncio - from maxapi.context import BaseContext from maxapi.dispatcher import Router from maxapi.enums.attachment import AttachmentType @@ -20,19 +21,20 @@ from maxapi.types.attachments.attachment import ( from maxapi.types.updates.message_callback import MessageCallback from maxapi.types.updates.message_created import MessageCreated -from bot.keyboards import done_ticket_kb, main_menu_kb +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 bot.states import TicketStates - -logger = get_logger("bot.handlers.create_ticket") 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 = { @@ -53,14 +55,67 @@ def _extract_attachments(event: MessageCreated) -> list[dict]: att_type = getattr(att, "type", None) if att_type not in _MEDIA_TYPES: continue - url = None + url: str | None = None + filename: str | None = None + duration: int | None = None payload = getattr(att, "payload", None) - if isinstance(payload, (PhotoAttachmentPayload, OtherAttachmentPayload)): + if isinstance(payload, PhotoAttachmentPayload): url = payload.url - result.append({"type": str(att_type), "url": 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 @@ -87,6 +142,8 @@ async def cb_create_ticket( ) +# ── Шаг 2: первое сообщение с описанием ────────────────────────────────────── + @router.message_created(states=TicketStates.waiting_description) async def on_ticket_description( event: MessageCreated, context: BaseContext @@ -99,10 +156,7 @@ async def on_ticket_description( text = (body.text or "").strip() if body else "" attachments = _extract_attachments(event) - await context.update_data( - description=text, - pending_attachments=attachments, - ) + await context.update_data(description=text, pending_attachments=attachments) await context.set_state(TicketStates.collecting_attachments) await bot_send( @@ -117,6 +171,8 @@ async def on_ticket_description( ) +# ── Шаг 3: дополнительные вложения/текст ───────────────────────────────────── + @router.message_created(states=TicketStates.collecting_attachments) async def on_ticket_attachment( event: MessageCreated, context: BaseContext @@ -133,10 +189,11 @@ async def on_ticket_attachment( 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], @@ -162,33 +219,104 @@ async def cb_done_ticket( ) return - ticket_id = await create_ticket( - org_id=bot_user["org_id"], - created_by=bot_user["id"], - description=description, - ) + await event.answer(notification="") pending_atts = data.get("pending_attachments", []) - 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), + + 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()], ) - for att in pending_atts: - await add_attachment( - ticket_id=ticket_id, - attachment_type=att["type"], - attachment_url=att.get("url"), - ) +# ── Шаг 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() - await event.answer(notification="Заявка создана!") - # ── Отправка в Intradesk ────────────────────────────────────────────────── + 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 - # Находим id контактного лица в Intradesk по телефону пользователя contact_person_id: int | None = None if intradesk_id and bot_user.get("phone"): db = get_db() @@ -196,48 +324,75 @@ async def cb_done_ticket( db, intradesk_id, bot_user["phone"] ) - # Ссылки на вложения (Max CDN) — добавятся в описание задачи + # ── Сохраняем локально ──────────────────────────────────────────────────── + 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")] - async def _create_in_intradesk(): - result = await create_intradesk_task( - name=description[:150], - description=description, - client_intradesk_id=intradesk_id, - contact_person_id=contact_person_id, - attachment_urls=att_urls or None, + 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") ) - if result: - iid = result.get("id") - if iid: - await set_intradesk_task_id(ticket_id, int(iid)) - logger.info("Intradesk task created: id=%s for ticket_id=%s", iid, ticket_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, + ) - asyncio.create_task(_create_in_intradesk()) - - # ── Подтверждение пользователю ──────────────────────────────────────────── - att_count = len(pending_atts) - confirm_text = ( - f"✅ Заявка успешно создана!\n" - f"Описание: {description[:200]}{'…' if len(description) > 200 else ''}" - ) - if att_count: - confirm_text += f"\nВложений: {att_count}" - confirm_text += "\n\nНаши специалисты свяжутся с вами в ближайшее время." + # ── Итоговое сообщение ──────────────────────────────────────────────────── + 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=confirm_text, + text="\n".join(lines), ) - menu_text = f"🏠 {org_name}\nВыберите действие:" if org_name else "🏠 Главное меню\nВыберите действие:" - await bot_send( event.bot, chat_id=chat_id, user_id=bot_user["max_user_id"], - text=menu_text, + text=await build_main_menu_text(bot_user), attachments=[main_menu_kb()], ) diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py index c0af9b0..3489297 100644 --- a/bot/handlers/menu.py +++ b/bot/handlers/menu.py @@ -6,22 +6,43 @@ from maxapi.context import BaseContext from bot.keyboards import main_menu_kb from bot.send_helper import bot_reply +from database.connection import get_db +from database.queries.intradesk import ( + count_active_tasks_for_local_org, + count_tasks_for_local_org, +) from database.queries.organizations import get_organization router = Router(router_id="menu") -async def _main_menu_text(bot_user: dict) -> str: +async def build_main_menu_text(bot_user: dict) -> str: + """Формирует текст главного меню с именем объекта и статистикой заявок.""" org_id = bot_user.get("org_id") + org_name: str | None = None + if org_id: org = await get_organization(org_id) org_name = org["name"] if org else None - else: - org_name = None + lines: list[str] = [] if org_name: - return f"🏠 {org_name}\nВыберите действие:" - return "🏠 Главное меню\nВыберите действие:" + lines.append(f"🏢 {org_name}") + else: + lines.append("🏠 Главное меню") + + if org_id: + db = get_db() + total = await count_tasks_for_local_org(db, org_id) + active = await count_active_tasks_for_local_org(db, org_id) + + if total == 0: + lines.append("Заявок пока нет.") + else: + lines.append(f"Заявок: {total} (активных: {active})") + + lines.append("\nВыберите действие:") + return "\n".join(lines) @router.message_callback(F.callback.payload == "main_menu") @@ -31,7 +52,7 @@ async def cb_main_menu( await context.set_state(None) await event.answer(notification="") - text = await _main_menu_text(bot_user) + text = await build_main_menu_text(bot_user) await bot_reply( event, text=text, diff --git a/bot/handlers/start.py b/bot/handlers/start.py index 09a796b..91aa192 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -30,7 +30,6 @@ from database.queries.bot_users import ( ) from database.queries.org_users import find_by_phone from database.queries.message_log import log_message -from database.queries.organizations import get_organization router = Router(router_id="start") @@ -224,13 +223,8 @@ async def on_contact_shared( ) await context.set_state(None) - org = await get_organization(org_user["org_id"]) - org_name = org["name"] if org else None - menu_text = ( - f"✅ Вы успешно авторизованы!\n\n🏠 {org_name}\nВыберите действие:" - if org_name - else "✅ Вы успешно авторизованы! Выберите действие:" - ) + from bot.handlers.menu import build_main_menu_text # local import to avoid circular + menu_text = "✅ Вы успешно авторизованы!\n\n" + await build_main_menu_text(bot_user) await bot_send( event.bot, diff --git a/bot/handlers/view_tickets.py b/bot/handlers/view_tickets.py index 6fb12bb..8056411 100644 --- a/bot/handlers/view_tickets.py +++ b/bot/handlers/view_tickets.py @@ -15,7 +15,7 @@ from maxapi.filters import F from maxapi.types.updates.message_callback import MessageCallback from bot.filters import PayloadStartsWith -from bot.keyboards import intradesk_tasks_list_kb, iticket_detail_kb +from bot.keyboards import intradesk_tasks_list_kb, iticket_detail_kb, menu_only_kb from bot.send_helper import bot_reply from database.connection import get_db from database.queries.intradesk import ( @@ -70,11 +70,11 @@ async def cb_view_tickets( ) -> None: await event.answer(notification="") text, tasks, total = await _tasks_page_text(bot_user["org_id"], 0) - kb = intradesk_tasks_list_kb(tasks, page=0, total=total) if tasks else None + kb = intradesk_tasks_list_kb(tasks, page=0, total=total) if tasks else menu_only_kb() await bot_reply( event, text=text, - attachments=[kb] if kb else None, + attachments=[kb], user_id=bot_user["max_user_id"], chat_id=event.message.recipient.chat_id if event.message else None, ) @@ -90,11 +90,11 @@ async def cb_itickets_page( except (IndexError, ValueError): page = 0 text, tasks, total = await _tasks_page_text(bot_user["org_id"], page) - kb = intradesk_tasks_list_kb(tasks, page=page, total=total) if tasks else None + kb = intradesk_tasks_list_kb(tasks, page=page, total=total) if tasks else menu_only_kb() await bot_reply( event, text=text, - attachments=[kb] if kb else None, + attachments=[kb], user_id=bot_user["max_user_id"], chat_id=event.message.recipient.chat_id if event.message else None, ) diff --git a/bot/keyboards.py b/bot/keyboards.py index 185ef6b..f726010 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -23,6 +23,26 @@ def contact_request_kb() -> Attachment: ) +def confirm_ticket_kb() -> Attachment: + """Клавиатура подтверждения создания заявки.""" + return ( + InlineKeyboardBuilder() + .row(CallbackButton(text="✅ Подтвердить", payload="confirm_ticket")) + .row(CallbackButton(text="✏️ Продолжить редактирование", payload="edit_ticket")) + .row(CallbackButton(text="❌ Отменить создание", payload="cancel_ticket")) + .as_markup() + ) + + +def menu_only_kb() -> Attachment: + """Клавиатура с единственной кнопкой возврата в главное меню.""" + return ( + InlineKeyboardBuilder() + .row(CallbackButton(text="🏠 Главное меню", payload="main_menu")) + .as_markup() + ) + + def done_ticket_kb() -> Attachment: return ( InlineKeyboardBuilder() diff --git a/bot/states.py b/bot/states.py index 2927df6..a71f9fe 100644 --- a/bot/states.py +++ b/bot/states.py @@ -8,3 +8,4 @@ class AuthStates(StatesGroup): class TicketStates(StatesGroup): waiting_description = State() # Ожидаем первое сообщение с описанием collecting_attachments = State() # Накапливаем доп. файлы/текст + confirming = State() # Предпросмотр — ждём подтверждения diff --git a/data/support.db b/data/support.db index 27402e1..bd84232 100644 Binary files a/data/support.db and b/data/support.db differ diff --git a/data/support.db-shm b/data/support.db-shm index 79890f8..1031b03 100644 Binary files a/data/support.db-shm and b/data/support.db-shm differ diff --git a/data/support.db-wal b/data/support.db-wal index 98a20f5..91b124b 100644 Binary files a/data/support.db-wal and b/data/support.db-wal differ diff --git a/database/queries/intradesk.py b/database/queries/intradesk.py index 18a5a30..ee70751 100644 --- a/database/queries/intradesk.py +++ b/database/queries/intradesk.py @@ -330,6 +330,23 @@ async def count_tasks_for_local_org( return row[0] if row else 0 +async def count_active_tasks_for_local_org( + db: aiosqlite.Connection, local_org_id: int +) -> int: + """Количество незакрытых заявок организации (closed_at IS NULL).""" + async with db.execute( + """ + SELECT COUNT(*) + FROM intradesk_tasks it + JOIN organizations o ON o.intradesk_id = it.client_id + WHERE o.id = ? AND it.closed_at IS NULL + """, + (local_org_id,), + ) as cur: + row = await cur.fetchone() + return row[0] if row else 0 + + async def get_task_for_local_org( db: aiosqlite.Connection, task_id: int, local_org_id: int ) -> dict | None: diff --git a/external/intradesk_api.py b/external/intradesk_api.py index 4962ca0..206a1ee 100644 --- a/external/intradesk_api.py +++ b/external/intradesk_api.py @@ -138,11 +138,15 @@ async def create_intradesk_task( ) return None result = resp.json() - logger.info( - "create_intradesk_task ok: id=%s number=%s", - result.get("id"), - result.get("tasknumber") or result.get("number"), + # API может вернуть как "Number"/"Id" (PascalCase), так и "number"/"id" + task_id = result.get("id") or result.get("Id") or result.get("ID") + task_number = ( + result.get("tasknumber") + or result.get("number") + or result.get("Number") + or result.get("TaskNumber") ) + logger.info("create_intradesk_task ok: id=%s number=%s", task_id, task_number) return result except Exception as exc: logger.exception("create_intradesk_task error: %s", exc)