diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 96843cb..15bae42 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,9 @@ "Bash(python -c \"import maxapi; import inspect; members = [m for m in dir\\(maxapi\\) if not m.startswith\\(''_''\\)]; print\\(members\\)\")", "Bash(python -c \"from maxapi import Bot; methods = [m for m in dir\\(Bot\\) if not m.startswith\\(''_''\\)]; print\\(methods\\)\")", "Bash(python -c \"from maxapi import Bot; import inspect; print\\(inspect.signature\\(Bot.edit_message\\)\\)\")", - "Bash(python -c \"from maxapi.types.updates.message_callback import MessageCallback; print\\([m for m in dir\\(MessageCallback\\) if not m.startswith\\(''_''\\)]\\)\")" + "Bash(python -c \"from maxapi.types.updates.message_callback import MessageCallback; print\\([m for m in dir\\(MessageCallback\\) if not m.startswith\\(''_''\\)]\\)\")", + "Bash(python -c \"from maxapi.types import SendedMessage; print\\(SendedMessage.__fields__ if hasattr\\(SendedMessage, ''__fields__''\\) else dir\\(SendedMessage\\)\\)\")", + "Bash(python -c \"from maxapi.context import State; s = State\\(\\); print\\(type\\(s\\), dir\\(s\\)\\)\")" ] } } diff --git a/bot/handlers/create_ticket.py b/bot/handlers/create_ticket.py index a41825c..b966c58 100644 --- a/bot/handlers/create_ticket.py +++ b/bot/handlers/create_ticket.py @@ -37,6 +37,54 @@ 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 # avoid circular at module level + state = await context.get_state() + creation_states = [ + TicketStates.waiting_description, + TicketStates.collecting_attachments, + TicketStates.confirming, + ] + 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, @@ -120,8 +168,11 @@ def _preview_text(org_name: str | None, description: str, pending_atts: list[dic 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": []}) + 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="") @@ -129,7 +180,7 @@ async def cb_create_ticket( if chat_id is None: return - await bot_send( + mid = await bot_send( event.bot, chat_id=chat_id, user_id=bot_user["max_user_id"], @@ -140,6 +191,7 @@ async def cb_create_ticket( ), attachments=[done_ticket_kb()], ) + await _track_mid(context, mid) # ── Шаг 2: первое сообщение с описанием ────────────────────────────────────── @@ -159,7 +211,7 @@ async def on_ticket_description( await context.update_data(description=text, pending_attachments=attachments) await context.set_state(TicketStates.collecting_attachments) - await bot_send( + mid = await bot_send( event.bot, chat_id=chat_id, user_id=user_id, @@ -169,6 +221,7 @@ async def on_ticket_description( ), attachments=[done_ticket_kb()], ) + await _track_mid(context, mid) # ── Шаг 3: дополнительные вложения/текст ───────────────────────────────────── @@ -227,13 +280,14 @@ async def cb_done_ticket( await context.set_state(TicketStates.confirming) - await bot_send( + 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б: "Продолжить редактирование" ────────────────────────────────────── @@ -252,7 +306,7 @@ async def cb_edit_ticket( if chat_id is None: return - await bot_send( + mid = await bot_send( event.bot, chat_id=chat_id, user_id=bot_user["max_user_id"], @@ -262,6 +316,7 @@ async def cb_edit_ticket( ), attachments=[done_ticket_kb()], ) + await _track_mid(context, mid) # ── Шаг 5в: "Отменить создание" ────────────────────────────────────────────── @@ -274,6 +329,10 @@ 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 @@ -301,6 +360,9 @@ async def cb_confirm_ticket( # Защита от двойного нажатия: читаем данные и сбрасываем состояние атомарно. # Повторный 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() diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py index 3489297..132758e 100644 --- a/bot/handlers/menu.py +++ b/bot/handlers/menu.py @@ -49,6 +49,8 @@ async def build_main_menu_text(bot_user: dict) -> str: async def cb_main_menu( 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 context.set_state(None) await event.answer(notification="") diff --git a/bot/handlers/view_tickets.py b/bot/handlers/view_tickets.py index 8056411..efbb693 100644 --- a/bot/handlers/view_tickets.py +++ b/bot/handlers/view_tickets.py @@ -1,13 +1,17 @@ """ -Обработчики просмотра заявок (Intradesk). +Обработчики просмотра заявок (Intradesk API). -Навигация через callback payload — все переходы редактируют текущее сообщение: +Заявки загружаются из Intradesk API по нажатию кнопки — только незакрытые. +Навигация через callback payload: "view_tickets" → список, страница 0 "itickets_page:{n}" → список, страница n - "iticket_detail:{id}" → карточка задачи + "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 @@ -15,24 +19,20 @@ 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, menu_only_kb +from bot.keyboards import iticket_detail_kb, menu_only_kb from bot.send_helper import bot_reply from database.connection import get_db -from database.queries.intradesk import ( - count_tasks_for_local_org, - get_task_for_local_org, - get_tasks_for_local_org, -) -from external.intradesk_api import get_task_last_comment +from database.queries.organizations import get_organization +from external.intradesk_api import get_client_open_tasks, get_task_last_comment router = Router(router_id="view_tickets") -_PAGE_SIZE = 8 +_PAGE_SIZE = 5 def _status_icon(status_name: str) -> str: sn = status_name.lower() - if "выполнен" in sn: + if "выполнен" in sn or "закрыт" in sn: return "🟢" if "работ" in sn: return "🟡" @@ -41,43 +41,144 @@ def _status_icon(status_name: str) -> str: return "⚪" -def _priority_icon(priority_name: str) -> str: - pn = priority_name.lower() - if "критич" in pn: - return "🔴" - if "высок" in pn: - return "🟠" - if "средн" in pn: - return "🟡" - return "⚪" +def _fmt_date(iso: str) -> str: + """'2026-03-26T09:57:11Z' → '26.03.2026'""" + if not iso or len(iso) < 10: + return iso or "—" + y, m, d = iso[:10].split("-") + return f"{d}.{m}.{y}" -async def _tasks_page_text(org_id: int, page: int) -> tuple[str, list[dict], int]: - """Возвращает (текст, задачи, всего).""" - db = get_db() - tasks = await get_tasks_for_local_org(db, org_id, page=page, page_size=_PAGE_SIZE) - total = await count_tasks_for_local_org(db, org_id) - if not tasks: - return "📭 У вашей организации пока нет заявок.", [], 0 +def _fmt_date_short(iso: str) -> str: + """'2026-03-26' → '26.03'""" + if not iso or len(iso) < 10: + return iso or "" + y, m, d = iso[:10].split("-") + return f"{d}.{m}" + + +def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str: pages = max(1, -(-total // _PAGE_SIZE)) - text = f"📋 Заявки — стр. {page + 1} из {pages} (всего: {total}):" - return text, tasks, total + lines = [f"📋 Активные заявки (всего: {total}) — стр. {page + 1}/{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})') + + return "\n".join(lines) + + +async def _load_and_show( + event: MessageCallback, + bot_user: dict, + page: int, + context: BaseContext, +) -> None: + """ + Загрузить страницу заявок из API и показать. + + Сразу редактирует сообщение на «⏳ Загрузка...», затем делает запрос к API. + Перед рендером проверяет 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()) + mid = event.message.body.mid if (event.message and event.message.body) else None + chat_id = event.message.recipient.chat_id if event.message else None + + # Записываем токен в контекст и сразу показываем загрузку + await context.set_data({"load_token": token}) + await bot_reply( + event, + text="⏳ Загружаем список заявок...", + attachments=None, + user_id=bot_user["max_user_id"], + chat_id=chat_id, + ) + + if not intradesk_client_id: + # Проверяем токен перед рендером + data = await context.get_data() + if data.get("load_token") != token: + return + await _edit_or_send(event.bot, mid, chat_id, + text="⚠️ Ваша организация не привязана к Intradesk. Обратитесь к администратору.", + 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: + return # Пользователь уже в другом флоу — не трогаем его экран + + # Сохраняем задачи в контексте для детального просмотра + await context.set_data({"tasks": tasks, "page": page, "total": total}) + + if not tasks: + await _edit_or_send(event.bot, mid, chat_id, + text="📭 Активных заявок нет.", + 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, 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 == "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="") - 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 menu_only_kb() - await bot_reply( - event, - text=text, - attachments=[kb], - user_id=bot_user["max_user_id"], - chat_id=event.message.recipient.chat_id if event.message else None, - ) + await _load_and_show(event, bot_user, page=0, context=context) @router.message_callback(PayloadStartsWith("itickets_page:")) @@ -89,15 +190,7 @@ async def cb_itickets_page( page = int(event.callback.payload.split(":")[1]) 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 menu_only_kb() - await bot_reply( - event, - text=text, - attachments=[kb], - user_id=bot_user["max_user_id"], - chat_id=event.message.recipient.chat_id if event.message else None, - ) + await _load_and_show(event, bot_user, page=page, context=context) @router.message_callback(PayloadStartsWith("iticket_detail:")) @@ -110,42 +203,35 @@ async def cb_iticket_detail( await event.answer(notification="Ошибка") return - db = get_db() - task = await get_task_for_local_org(db, task_id, bot_user["org_id"]) - - if task is None: - await event.answer(notification="Нет доступа") - return - await event.answer(notification="") - sicon = _status_icon(task.get("status_name") or "") - picon = _priority_icon(task.get("priority_name") or "") + # Ищем задачу в кэше контекста (загружена при показе списка) + 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 bot_reply( + event, + text="⚠️ Заявка не найдена. Вернитесь к списку.", + attachments=[iticket_detail_kb()], + user_id=bot_user["max_user_id"], + chat_id=event.message.recipient.chat_id if event.message else None, + ) + return + + icon = _status_icon(task.get("status_name") or "") num = task.get("task_number") or task["id"] + lines = [ f"📌 Заявка #{num}", - f"Статус: {sicon} {task.get('status_name') or '—'}", - f"Приоритет: {picon} {task.get('priority_name') or '—'}", + 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("service_name"): - lines.append(f"Сервис: {task['service_name']}") - if task.get("executor_name"): - lines.append(f"Исполнитель: {task['executor_name']}") - lines.append(f"Создана: {(task.get('created_at') or '')[:10] or '—'}") - lines.append(f"Обновлена: {(task.get('updated_at') or '')[:10] or '—'}") - if task.get("name"): lines += ["", f"📄 {task['name']}"] - if task.get("description"): - desc = re.sub(r"<[^>]+>", "", task["description"]).strip() - if desc: - if len(desc) > 800: - desc = desc[:800] + "…" - lines += ["", f"💬 {desc}"] - - # Последний комментарий — запрашиваем из Intradesk API + # Последний комментарий — отдельный запрос к history API comment = await get_task_last_comment(task["id"]) if comment and comment.get("text"): ctext = comment["text"] diff --git a/bot/send_helper.py b/bot/send_helper.py index e7e262e..d1c58e5 100644 --- a/bot/send_helper.py +++ b/bot/send_helper.py @@ -9,19 +9,23 @@ async def bot_send( user_id: int | None, text: str, attachments=None, -) -> None: - """Отправить новое сообщение.""" +) -> str | None: + """Отправить новое сообщение. Возвращает message_id (mid) или None.""" kw: dict = {"chat_id": chat_id, "text": text} if attachments is not None: kw["attachments"] = attachments - await bot.send_message(**kw) + result = await bot.send_message(**kw) + mid: str | None = None + if result and result.message and result.message.body: + mid = result.message.body.mid await log_message( direction="out", max_user_id=user_id, max_chat_id=chat_id, - message_id=None, + message_id=mid, text=text, ) + return mid async def bot_edit( diff --git a/data/support.db-shm b/data/support.db-shm index 1031b03..f99e548 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 91b124b..eddd421 100644 Binary files a/data/support.db-wal and b/data/support.db-wal differ diff --git a/external/intradesk_api.py b/external/intradesk_api.py index 206a1ee..06b41d9 100644 --- a/external/intradesk_api.py +++ b/external/intradesk_api.py @@ -60,6 +60,88 @@ async def get_task_last_comment(task_id: int) -> dict | None: return None +async def get_client_open_tasks( + client_id: int, + page: int = 0, + page_size: int = 5, +) -> dict: + """ + Получить незакрытые заявки клиента из Intradesk API. + + Возвращает {"tasks": [...], "total": int}. + Каждая задача содержит поля из OData + "last_comment" (dict или None). + """ + key = config.INTRADESK_TASKS_API_KEY + if not key: + return {"tasks": [], "total": 0} + + skip = page * page_size + url = ( + f"{config.INTRADESK_BASE_URL.rstrip('/')}/tasklist/odata/v3/tasks" + f"?ApiKey={key}" + f"&$filter=(clientid eq {client_id}) and (closedat eq null) and (isarchived eq false)" + f"&$orderby=updatedat desc" + f"&$top={page_size}&$skip={skip}" + f"&$count=true" + ) + try: + async with httpx.AsyncClient() as client: + resp = await client.get(url, timeout=20.0) + if resp.status_code != 200: + logger.warning("get_client_open_tasks client=%s → HTTP %s", client_id, resp.status_code) + return {"tasks": [], "total": 0} + data = resp.json() + except Exception as exc: + logger.warning("get_client_open_tasks error: %s", exc) + return {"tasks": [], "total": 0} + + total = data.get("@odata.count") or 0 + raw_tasks = data.get("value") or [] + + # Словари для разрешения имён + dicts_map: dict[int, str] = {} + for d in data.get("dictionaries") or []: + did = d.get("id") + dname = d.get("name") or "" + if isinstance(dname, dict): + dname = dname.get("ru") or dname.get("en") or "" + if did: + dicts_map[did] = dname + + tasks = [] + for t in raw_tasks: + status_id = t.get("status") + last_comment = _extract_last_comment_from_lifetime(t.get("lifetime")) + tasks.append({ + "id": t.get("id"), + "task_number": t.get("tasknumber"), + "name": t.get("name") or "", + "status_name": dicts_map.get(status_id, "") if status_id else "", + "created_at": t.get("createdat") or "", + "updated_at": t.get("updatedat") or "", + "last_comment": last_comment, + }) + + return {"tasks": tasks, "total": total} + + +def _extract_last_comment_from_lifetime(lifetime: dict | None) -> dict | None: + """Извлечь последний комментарий из поля lifetime задачи (если последнее событие — комментарий).""" + if not lifetime: + return None + for entry in (lifetime.get("data") or []): + for ev in (entry.get("events") or {}).get("data") or []: + if ev.get("blockname") == "comment" and ev.get("type") == 50: + raw_date = entry.get("eventat") or "" + short_date = raw_date[:10] # "2026-03-26" + return { + "text": ev.get("stringvalue") or "", + "author": entry.get("username") or "—", + "date": short_date, + } + return None + + async def create_intradesk_task( name: str, description: str,