""" Обработчики просмотра заявок (Intradesk API). Заявки загружаются из Intradesk API по нажатию кнопки — только незакрытые. Навигация через callback payload: "view_tickets" → список, страница 0 "itickets_page:{n}" → список, страница n "iticket_detail:{id}" → карточка задачи (из кэша в context) "main_menu" → в menu.py """ from __future__ import annotations import re import time from maxapi.context import BaseContext from maxapi.dispatcher import Router from maxapi.filters import F from maxapi.types.updates.message_callback import MessageCallback import config from bot.filters import PayloadStartsWith from bot.flow_config import PAYLOADS, TRANSITIONS from bot.handlers.create_ticket import _extract_attachments from bot.keyboards import comment_input_kb, iticket_detail_kb, main_menu_kb, menu_only_kb from bot.nav_helper import nav_finish, nav_prepare, nav_set from bot.send_helper import bot_send from bot.texts import CommentTexts, TicketDetailTexts, TicketListTexts 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 external.intradesk_api import ( add_task_comment, close_task, get_client_open_tasks, get_task_comments, get_task_last_comment, invalidate_client_cache, invalidate_task_comment, upload_attachments, ) router = Router(router_id="view_tickets") _PAGE_SIZE = 5 def _status_icon(status_name: str) -> str: sn = status_name.lower() if "выполнен" in sn or "закрыт" in sn: return "🟢" if "работ" in sn: return "🟡" if "открыт" in sn: return "🔴" return "⚪" def _fmt_date(iso: str) -> str: """'2026-03-26T09:57:11Z' → '26.03.2026 09:57'""" if not iso or len(iso) < 10: return iso or "—" y, m, d = iso[:10].split("-") # iso может быть "2026-03-26T09:57:11Z" или "2026-03-26 09:57" time_part = iso[11:16] if len(iso) >= 16 else "" return f"{d}.{m}.{y}" + (f" {time_part}" if time_part else "") def _fmt_date_short(iso: str) -> str: """'2026-03-26 11:54' → '26.03 11:54'""" if not iso or len(iso) < 10: return iso or "" y, m, d = iso[:10].split("-") time_part = iso[11:16] if len(iso) >= 16 else "" return f"{d}.{m}" + (f" {time_part}" if time_part else "") def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str: pages = max(1, -(-total // _PAGE_SIZE)) lines = [TicketListTexts.HEADER.format(total=total, page=page + 1, pages=pages)] for t in tasks: icon = _status_icon(t.get("status_name") or "") num = t.get("task_number") or t.get("id") or "?" name = (t.get("name") or "—")[:60] created = _fmt_date(t.get("created_at") or "") status = t.get("status_name") or "—" lines += [ "", "─" * 20, f"📌 №{num} · {created}", f"{name}", f"{icon} {status}", ] comment = t.get("last_comment") if comment and comment.get("text"): ctext = comment["text"][:80] + ("…" if len(comment["text"]) > 80 else "") author_short = (comment.get("author") or "—").split()[0] cdate = _fmt_date_short(comment.get("date") or "") lines.append(f'💬 "{ctext}" — {author_short} ({cdate})') else: lines.append(TicketListTexts.NO_COMMENTS) return "\n".join(lines) async def _delete_msg(bot, mid: str | None) -> None: """Тихо удалить сообщение по mid.""" if not mid: return try: await bot.delete_message(message_id=mid) except Exception: pass async def _load_and_show( event: MessageCallback, bot_user: dict, page: int, context: BaseContext, nav_mid: str | None = None, ) -> None: """ Загрузить страницу заявок из API и показать. nav_mid — уже отображённый индикатор загрузки (из nav_prepare). Если None — отправляет новое сообщение самостоятельно. Редактирует nav_mid результатом; перед рендером проверяет load_token. """ org_id = bot_user.get("org_id") intradesk_client_id: int | None = None if org_id: org = await get_organization(org_id) intradesk_client_id = org.get("intradesk_id") if org else None token = str(time.monotonic_ns()) chat_id = event.message.recipient.chat_id if event.message else None # Если nav_mid не задан — отправляем loading сами if not nav_mid: nav_mid = await bot_send( event.bot, chat_id=chat_id, user_id=bot_user["max_user_id"], text=TicketListTexts.LOADING, attachments=[], ) # Сохраняем токен и nav_mid (остальные ключи контекста пока не важны) data = await context.get_data() data.update({"load_token": token, "nav_mid": nav_mid}) await context.set_data(data) if not intradesk_client_id: data = await context.get_data() if data.get("load_token") != token: await _delete_msg(event.bot, nav_mid) return await _edit_or_send(event.bot, nav_mid, chat_id, text=TicketListTexts.NO_ORG, attachments=[menu_only_kb()]) return # Запрос к API result = await get_client_open_tasks(intradesk_client_id, page=page, page_size=_PAGE_SIZE) tasks = result["tasks"] total = result["total"] # Проверяем токен — не ушёл ли пользователь пока грузили data = await context.get_data() if data.get("load_token") != token: await _delete_msg(event.bot, nav_mid) return # Сохраняем задачи в контексте для детального просмотра data.update({"tasks": tasks, "page": page, "total": total, "nav_mid": nav_mid}) await context.set_data(data) if not tasks: await _edit_or_send(event.bot, nav_mid, chat_id, text=TicketListTexts.EMPTY, attachments=[menu_only_kb()]) return from bot.keyboards import intradesk_tasks_list_kb text = _build_tasks_text(tasks, page, total) kb = intradesk_tasks_list_kb(tasks, page=page, total=total, page_size=_PAGE_SIZE) await _edit_or_send(event.bot, nav_mid, chat_id, text=text, attachments=[kb]) async def _edit_or_send(bot, mid: str | None, chat_id: int | None, *, text: str, attachments) -> None: """Редактировать сообщение по mid или отправить новое если mid недоступен.""" if mid: try: await bot.edit_message( message_id=mid, text=text, attachments=attachments if attachments is not None else [], ) return except Exception: pass if chat_id: await bot.send_message(chat_id=chat_id, text=text, attachments=attachments or []) @router.message_callback(F.callback.payload == PAYLOADS.VIEW_TICKETS) async def cb_view_tickets( event: MessageCallback, context: BaseContext, bot_user: dict ) -> None: from bot.handlers.create_ticket import cleanup_ticket_flow await cleanup_ticket_flow(event.bot, context) await event.answer(notification="") chat_id = event.message.recipient.chat_id if event.message else None nav_mid = await nav_prepare( event, context, loading_text=TicketListTexts.LOADING, chat_id=chat_id, user_id=bot_user["max_user_id"], ) await _load_and_show(event, bot_user, page=0, context=context, nav_mid=nav_mid) @router.message_callback(PayloadStartsWith(PAYLOADS.PFX_ITICKETS_PAGE)) async def cb_itickets_page( event: MessageCallback, context: BaseContext, bot_user: dict ) -> None: await event.answer(notification="") try: page = int(event.callback.payload.split(":")[1]) except (IndexError, ValueError): page = 0 chat_id = event.message.recipient.chat_id if event.message else None nav_mid = await nav_prepare( event, context, loading_text=TicketListTexts.LOADING, chat_id=chat_id, user_id=bot_user["max_user_id"], ) await _load_and_show(event, bot_user, page=page, context=context, nav_mid=nav_mid) @router.message_callback(PayloadStartsWith(PAYLOADS.PFX_ITICKET_DETAIL)) async def cb_iticket_detail( event: MessageCallback, context: BaseContext, bot_user: dict ) -> None: try: task_id = int(event.callback.payload.split(":")[1]) except (IndexError, ValueError): await event.answer(notification=TicketDetailTexts.GENERIC_ERROR) return await event.answer(notification="") chat_id = event.message.recipient.chat_id if event.message else None # Если пришли сюда из комментарийного флоу (кнопка «Назад») — # удаляем промпт-сообщение и сбрасываем state, сохраняя кэш задач. state = await context.get_state() if state == TRANSITIONS["comment:start"]: d = await context.get_data() for mid in (d.get("flow_message_ids") or []): await _delete_msg(event.bot, mid) await context.set_state(TRANSITIONS["comment:done"]) # Сохраняем кэш задач, но сбрасываем nav_mid (промпт уже удалён) await context.set_data({ "tasks": d.get("tasks"), "page": d.get("page"), "total": d.get("total"), }) # Показываем ⏳ (edit-in-place если меню/список последнее, иначе новое) nav_mid = await nav_prepare( event, context, loading_text=TicketDetailTexts.LOADING, chat_id=chat_id, user_id=bot_user["max_user_id"], ) # Ищем задачу в кэше контекста (загружена при показе списка) data = await context.get_data() task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None) if task is None: await nav_finish( event.bot, context, nav_mid, chat_id, bot_user["max_user_id"], text=TicketDetailTexts.NOT_FOUND, attachments=[iticket_detail_kb()], ) return icon = _status_icon(task.get("status_name") or "") num = task.get("task_number") or task["id"] lines = [ f"📌 Заявка #{num}", f"Статус: {icon} {task.get('status_name') or '—'}", f"Создана: {_fmt_date(task.get('created_at') or '')}", f"Обновлена: {_fmt_date(task.get('updated_at') or '')}", ] if task.get("name"): lines += ["", f"📄 {task['name']}"] if task.get("description"): desc = task["description"] if len(desc) > 600: desc = desc[:600] + "…" lines += ["", TicketDetailTexts.DESCRIPTION_HEADER, desc] # Последние 10 комментариев из history API comments = await get_task_comments(task["id"], count=10) if comments: lines += ["", TicketDetailTexts.COMMENTS_HEADER.format(count=len(comments))] for c in comments: ctext = c["text"] if len(ctext) > 300: ctext = ctext[:300] + "…" lines += [ "", "─" * 16, f"{c['date']} — {c['author']}", ctext, ] else: lines += ["", TicketDetailTexts.NO_COMMENTS] await nav_finish( event.bot, context, nav_mid, chat_id, bot_user["max_user_id"], text="\n".join(lines), attachments=[iticket_detail_kb(task_id=task["id"], can_close=bool(config.INTRADESK_CLOSE_STATUS_ID))], ) from database.queries.reply_context import set_reply_context await set_reply_context(bot_user["max_user_id"], task_id, task.get("task_number")) # ── Закрыть заявку ──────────────────────────────────────────────────────────── @router.message_callback(PayloadStartsWith(PAYLOADS.PFX_ITICKET_CLOSE)) async def cb_close_ticket( event: MessageCallback, context: BaseContext, bot_user: dict ) -> None: try: task_id = int(event.callback.payload.split(":")[1]) except (IndexError, ValueError): await event.answer(notification=TicketDetailTexts.GENERIC_ERROR) return await event.answer(notification="") # Ищем задачу в кэше data = await context.get_data() task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None) task_number = task.get("task_number") if task else None chat_id = event.message.recipient.chat_id if event.message else None if not task_number: nav_mid = await nav_prepare( event, context, loading_text=TicketDetailTexts.CLOSING, chat_id=chat_id, user_id=bot_user["max_user_id"], ) await nav_finish( event.bot, context, nav_mid, chat_id, bot_user["max_user_id"], text=TicketDetailTexts.CLOSE_NUMBER_ERROR, attachments=[iticket_detail_kb()], ) return nav_mid = await nav_prepare( event, context, loading_text=TicketDetailTexts.CLOSING, chat_id=chat_id, user_id=bot_user["max_user_id"], ) # Получаем contact_person_id intradesk_id: int | None = None contact_person_id: int | None = None if bot_user.get("org_id"): org = await get_organization(bot_user["org_id"]) intradesk_id = org.get("intradesk_id") if org else 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"] ) ok = await close_task(task_number=task_number, contact_person_id=contact_person_id) if ok: # Сбрасываем кэш — задача теперь закрыта invalidate_task_comment(task_id) if intradesk_id: invalidate_client_cache(intradesk_id) num_str = f" #{task_number}" if task_number else "" await nav_finish( event.bot, context, nav_mid, chat_id, bot_user["max_user_id"], text=TicketDetailTexts.CLOSED.format(num_str=num_str), attachments=[menu_only_kb()], ) else: await nav_finish( event.bot, context, nav_mid, chat_id, bot_user["max_user_id"], text=TicketDetailTexts.CLOSE_ERROR, attachments=[iticket_detail_kb(task_id=task_id, can_close=bool(config.INTRADESK_CLOSE_STATUS_ID))], ) # ── Оставить комментарий ────────────────────────────────────────────────────── @router.message_callback(PayloadStartsWith(PAYLOADS.PFX_ITICKET_COMMENT)) async def cb_add_comment( event: MessageCallback, context: BaseContext, bot_user: dict ) -> None: # Защита от спама: если уже в режиме ввода комментария — игнорируем повторный клик state = await context.get_state() if state == TRANSITIONS["comment:start"]: await event.answer(notification="") return try: task_id = int(event.callback.payload.split(":")[1]) except (IndexError, ValueError): await event.answer(notification=TicketDetailTexts.GENERIC_ERROR) return await event.answer(notification="") # Ищем task_number в кэше контекста; сохраняем кэш задач для кнопки «Назад» data = await context.get_data() task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None) task_number = task.get("task_number") if task else None await context.set_state(TRANSITIONS["comment:start"]) await context.set_data({ "comment_task_id": task_id, "comment_task_number": task_number, "flow_message_ids": [], # Сохраняем кэш списка чтобы кнопка «Назад» нашла задачу "tasks": data.get("tasks"), "page": data.get("page"), "total": data.get("total"), }) chat_id = event.message.recipient.chat_id if event.message else None if chat_id is None: return num_str = f" к заявке #{task_number}" if task_number else "" mid = await bot_send( event.bot, chat_id=chat_id, user_id=bot_user["max_user_id"], text=CommentTexts.PROMPT.format(num_str=num_str), attachments=[comment_input_kb(task_id)], ) # Сохраняем mid для возможной очистки if mid: d = await context.get_data() d["flow_message_ids"] = [mid] await context.set_data(d) @router.message_created(states=TRANSITIONS["comment:start"]) async def on_comment_text( event, context: BaseContext, bot_user: dict ) -> 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 "" file_atts = _extract_attachments(event) if not text and not file_atts: await bot_send( event.bot, chat_id=chat_id, user_id=user_id, text=CommentTexts.EMPTY_ERROR, attachments=[menu_only_kb()], ) return data = await context.get_data() task_id = data.get("comment_task_id") task_number = data.get("comment_task_number") # Сбрасываем состояние до отправки API — защита от повторов await context.clear() # Получаем данные организации (нужны и для contact_person_id, и для инвалидации кэша) intradesk_id: int | None = None contact_person_id: int | None = None if bot_user.get("org_id"): org = await get_organization(bot_user["org_id"]) intradesk_id = org.get("intradesk_id") if org else 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"] ) # Загружаем файлы в Intradesk (target=Comment, task_id=Intradesk внутренний ID) if file_atts: file_atts = await upload_attachments( file_atts, task_id=task_id or 0, target="Comment" ) comment_text = text or CommentTexts.FILE_ONLY ok = await add_task_comment( task_number=task_number, text=comment_text, contact_person_id=contact_person_id, attachments=file_atts or None, ) if ok: # Сбрасываем кэш — следующий переход в меню/список покажет свежие данные if task_id: invalidate_task_comment(task_id) if intradesk_id: invalidate_client_cache(intradesk_id) num_str = f" к заявке #{task_number}" if task_number else "" result_text = CommentTexts.SUCCESS.format(num_str=num_str) if ok else CommentTexts.ERROR menu_mid = await bot_send( event.bot, chat_id=chat_id, user_id=user_id, text=result_text, attachments=[main_menu_kb()], ) await nav_set(context, menu_mid)