diff --git a/bot/handlers/create_ticket.py b/bot/handlers/create_ticket.py index f407f50..21c8865 100644 --- a/bot/handlers/create_ticket.py +++ b/bot/handlers/create_ticket.py @@ -24,6 +24,7 @@ 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 @@ -31,7 +32,7 @@ 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 +from external.intradesk_api import create_intradesk_task, invalidate_client_cache, upload_attachments logger = get_logger("bot.handlers.create_ticket") @@ -188,7 +189,7 @@ async def cb_create_ticket( text=( "📝 Опишите вашу проблему.\n\n" "Вы также можете прикрепить файлы, фото или голосовые сообщения.\n" - "Когда закончите — нажмите кнопку «Готово»." + "Когда закончите — нажмите «👁 Перейти к предпросмотру»." ), attachments=[done_ticket_kb()], ) @@ -207,9 +208,41 @@ async def on_ticket_description( body = event.message.body text = (body.text or "").strip() if body else "" - attachments = _extract_attachments(event) + new_atts = _extract_attachments(event) - await context.update_data(description=text, pending_attachments=attachments) + 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( @@ -218,7 +251,7 @@ async def on_ticket_description( user_id=user_id, text=( "✏️ Описание принято. Вы можете добавить ещё файлы " - "или нажмите «Готово» для отправки заявки." + "или нажмите «👁 Перейти к предпросмотру»." ), attachments=[done_ticket_kb()], ) @@ -313,7 +346,7 @@ async def cb_edit_ticket( user_id=bot_user["max_user_id"], text=( "✏️ Продолжайте добавлять описание или вложения.\n" - "Нажмите «Готово» когда будете готовы." + "Нажмите «👁 Перейти к предпросмотру» когда будете готовы." ), attachments=[done_ticket_kb()], ) @@ -324,7 +357,11 @@ async def cb_edit_ticket( @router.message_callback( F.callback.payload == "cancel_ticket", - states=TicketStates.confirming, + states=[ + TicketStates.waiting_description, + TicketStates.collecting_attachments, + TicketStates.confirming, + ], ) async def cb_cancel_ticket( event: MessageCallback, context: BaseContext, bot_user: dict @@ -340,13 +377,14 @@ async def cb_cancel_ticket( if chat_id is None: return - await bot_send( + 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а: "Подтвердить" → создание ───────────────────────────────────────── @@ -373,9 +411,12 @@ async def cb_confirm_ticket( if chat_id is None: return - #await event.answer(notification="Отправляем заявку…") 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 @@ -407,14 +448,13 @@ async def cb_confirm_ticket( # ── Отправка в Intradesk (ждём, чтобы получить номер) ──────────────────── task_name = org_name or description[:150] - att_urls = [a["url"] for a in pending_atts if a.get("url")] 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, + attachments=pending_atts or None, ) intradesk_number: int | None = None @@ -455,10 +495,11 @@ async def cb_confirm_ticket( text="\n".join(lines), ) - await bot_send( + 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) diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py index f49f013..96de0c2 100644 --- a/bot/handlers/menu.py +++ b/bot/handlers/menu.py @@ -7,7 +7,7 @@ from maxapi.filters import F from maxapi.types.updates.message_callback import MessageCallback from bot.keyboards import main_menu_kb -from bot.send_helper import bot_reply +from bot.nav_helper import nav_finish, nav_prepare from database.queries.organizations import get_organization from external.intradesk_api import count_client_open_tasks, get_client_last_comment @@ -51,7 +51,6 @@ async def build_main_menu_text(bot_user: dict) -> str: if len(ctext) > 120: ctext = ctext[:120] + "…" raw_date = last_comment.get("date") or "" - # raw_date приходит как "2026-03-26 11:54" date_part = raw_date[:10] time_part = raw_date[11:16] if len(raw_date) >= 16 else "" if len(date_part) == 10: @@ -86,11 +85,23 @@ async def cb_main_menu( await context.set_state(None) await event.answer(notification="") + chat_id = event.message.recipient.chat_id if event.message else None + + # Показываем «⏳» — edit-in-place если это последнее навигационное сообщение + mid = await nav_prepare( + event, context, + loading_text="⏳ Загружаем меню...", + chat_id=chat_id, + user_id=bot_user["max_user_id"], + ) + text = await build_main_menu_text(bot_user) - await bot_reply( - event, + + await nav_finish( + event.bot, context, + mid=mid, + chat_id=chat_id, + user_id=bot_user["max_user_id"], text=text, attachments=[main_menu_kb()], - user_id=bot_user["max_user_id"], - chat_id=event.message.recipient.chat_id if event.message else None, ) diff --git a/bot/handlers/start.py b/bot/handlers/start.py index e6d4dab..1927d4c 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -18,6 +18,7 @@ from maxapi.filters.command import Command from bot.keyboards import contact_request_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 AuthStates @@ -70,13 +71,14 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None: await context.set_state(None) from bot.handlers.menu import build_main_menu_text menu_text = "👋 С возвращением!\n\n" + await build_main_menu_text(bot_user) - await bot_send( + mid = await bot_send( event.bot, chat_id=event.chat_id, user_id=user.user_id, text=menu_text, attachments=[main_menu_kb()], ) + await nav_set(context, mid) return # Новый пользователь — запрашиваем контакт @@ -146,13 +148,14 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None: await context.set_state(None) from bot.handlers.menu import build_main_menu_text menu_text = await build_main_menu_text(bot_user) - await bot_send( + mid = await bot_send( event.bot, chat_id=chat_id, user_id=user_id, text=menu_text, attachments=[main_menu_kb()], ) + await nav_set(context, mid) @router.message_created(ContactFilter(), states=AuthStates.waiting_contact) @@ -228,16 +231,19 @@ async def on_contact_shared( ) await context.set_state(None) + bot_user = await get_bot_user_by_max_id(user_id) + 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( + mid = await bot_send( event.bot, chat_id=chat_id, user_id=user_id, text=menu_text, attachments=[main_menu_kb()], ) + await nav_set(context, mid) # ── /menu ───────────────────────────────────────────────────────────────────── @@ -258,13 +264,14 @@ async def on_menu_command(event: MessageCreated, context: BaseContext) -> None: await context.set_state(None) from bot.handlers.menu import build_main_menu_text - await bot_send( + mid = await bot_send( event.bot, chat_id=chat_id, user_id=user_id, text=await build_main_menu_text(bot_user), attachments=[main_menu_kb()], ) + await nav_set(context, mid) # ── /cancel ─────────────────────────────────────────────────────────────────── @@ -300,10 +307,11 @@ async def on_cancel_command(event: MessageCreated, context: BaseContext) -> None await context.set_state(None) from bot.handlers.menu import build_main_menu_text - await bot_send( + mid = await bot_send( event.bot, chat_id=chat_id, user_id=user_id, text=cancel_text + await build_main_menu_text(bot_user), attachments=[main_menu_kb()], ) + await nav_set(context, mid) diff --git a/bot/handlers/view_tickets.py b/bot/handlers/view_tickets.py index 154eaf2..b1fa3b1 100644 --- a/bot/handlers/view_tickets.py +++ b/bot/handlers/view_tickets.py @@ -20,8 +20,10 @@ from maxapi.types.updates.message_callback import MessageCallback import config from bot.filters import PayloadStartsWith +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.send_helper import bot_reply, bot_send +from bot.nav_helper import nav_finish, nav_prepare, nav_set +from bot.send_helper import bot_send from bot.states import CommentStates from database.connection import get_db from database.queries.intradesk import get_intradesk_contact_id_for_phone @@ -34,6 +36,7 @@ from external.intradesk_api import ( get_task_last_comment, invalidate_client_cache, invalidate_task_comment, + upload_attachments, ) router = Router(router_id="view_tickets") @@ -102,18 +105,29 @@ def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str: 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 и показать. - Сразу редактирует сообщение на «⏳ Загрузка...», затем делает запрос к API. - Перед рендером проверяет load_token — если пользователь успел уйти - в другой флоу, контекст изменится и мы тихо выходим. + nav_mid — уже отображённый индикатор загрузки (из nav_prepare). + Если None — отправляет новое сообщение самостоятельно. + Редактирует nav_mid результатом; перед рендером проверяет load_token. """ org_id = bot_user.get("org_id") intradesk_client_id: int | None = None @@ -121,27 +135,30 @@ async def _load_and_show( 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, - ) + # Если 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="⏳ Загружаем список заявок...", + 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, mid, chat_id, + await _edit_or_send(event.bot, nav_mid, chat_id, text="⚠️ Ваша организация не привязана к Intradesk. Обратитесь к администратору.", attachments=[menu_only_kb()]) return @@ -154,13 +171,15 @@ async def _load_and_show( # Проверяем токен — не ушёл ли пользователь пока грузили data = await context.get_data() if data.get("load_token") != token: - return # Пользователь уже в другом флоу — не трогаем его экран + await _delete_msg(event.bot, nav_mid) + return # Сохраняем задачи в контексте для детального просмотра - await context.set_data({"tasks": tasks, "page": page, "total": total}) + 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, mid, chat_id, + await _edit_or_send(event.bot, nav_mid, chat_id, text="📭 Активных заявок нет.", attachments=[menu_only_kb()]) return @@ -168,7 +187,7 @@ async def _load_and_show( 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]) + 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: @@ -194,7 +213,15 @@ async def cb_view_tickets( from bot.handlers.create_ticket import cleanup_ticket_flow await cleanup_ticket_flow(event.bot, context) await event.answer(notification="") - await _load_and_show(event, bot_user, page=0, context=context) + + chat_id = event.message.recipient.chat_id if event.message else None + nav_mid = await nav_prepare( + event, context, + loading_text="⏳ Загружаем список заявок...", + 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("itickets_page:")) @@ -206,7 +233,15 @@ async def cb_itickets_page( page = int(event.callback.payload.split(":")[1]) except (IndexError, ValueError): page = 0 - await _load_and_show(event, bot_user, page=page, context=context) + + chat_id = event.message.recipient.chat_id if event.message else None + nav_mid = await nav_prepare( + event, context, + loading_text="⏳ Загружаем список заявок...", + 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("iticket_detail:")) @@ -220,6 +255,7 @@ async def cb_iticket_detail( return await event.answer(notification="") + chat_id = event.message.recipient.chat_id if event.message else None # Если пришли сюда из комментарийного флоу (кнопка «Назад») — # удаляем промпт-сообщение и сбрасываем state, сохраняя кэш задач. @@ -227,28 +263,32 @@ async def cb_iticket_detail( if state == CommentStates.waiting_text: d = await context.get_data() for mid in (d.get("flow_message_ids") or []): - try: - await event.bot.delete_message(message_id=mid) - except Exception: - pass + await _delete_msg(event.bot, mid) await context.set_state(None) + # Сохраняем кэш задач, но сбрасываем 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="⏳ Загружаем заявку...", + 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 bot_reply( - event, + await nav_finish( + event.bot, context, nav_mid, chat_id, bot_user["max_user_id"], 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 @@ -280,19 +320,17 @@ async def cb_iticket_detail( ctext = ctext[:300] + "…" lines += [ "", - f"─" * 16, + "─" * 16, f"{c['date']} — {c['author']}", ctext, ] else: lines += ["", "💬 Комментариев нет"] - await bot_reply( - event, + 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))], - user_id=bot_user["max_user_id"], - chat_id=event.message.recipient.chat_id if event.message else None, ) @@ -315,16 +353,29 @@ async def cb_close_ticket( 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: - await bot_reply( - event, + nav_mid = await nav_prepare( + event, context, + loading_text="⏳ Закрываем заявку...", + 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="⚠️ Не удалось определить номер заявки. Вернитесь к списку.", attachments=[iticket_detail_kb()], - user_id=bot_user["max_user_id"], - chat_id=event.message.recipient.chat_id if event.message else None, ) return + nav_mid = await nav_prepare( + event, context, + loading_text="⏳ Закрываем заявку...", + 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 @@ -345,20 +396,16 @@ async def cb_close_ticket( if intradesk_id: invalidate_client_cache(intradesk_id) num_str = f" #{task_number}" if task_number else "" - await bot_reply( - event, + await nav_finish( + event.bot, context, nav_mid, chat_id, bot_user["max_user_id"], text=f"✅ Заявка{num_str} закрыта.", attachments=[menu_only_kb()], - user_id=bot_user["max_user_id"], - chat_id=event.message.recipient.chat_id if event.message else None, ) else: - await bot_reply( - event, + await nav_finish( + event.bot, context, nav_mid, chat_id, bot_user["max_user_id"], text="⚠️ Не удалось закрыть заявку. Попробуйте позже.", attachments=[iticket_detail_kb(task_id=task_id, can_close=bool(config.INTRADESK_CLOSE_STATUS_ID))], - user_id=bot_user["max_user_id"], - chat_id=event.message.recipient.chat_id if event.message else None, ) @@ -403,7 +450,8 @@ async def cb_add_comment( user_id=bot_user["max_user_id"], text=( f"💬 Напишите комментарий{num_str}:\n\n" - "Отправьте текстовое сообщение или нажмите «← Назад к заявке» для отмены." + "Можно отправить текст, файл, фото или голосовое сообщение.\n" + "Нажмите «← Назад к заявке» для отмены." ), attachments=[comment_input_kb(task_id)], ) @@ -424,12 +472,14 @@ async def on_comment_text( body = event.message.body text = (body.text or "").strip() if body else "" - if not text: + 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="❗ Комментарий не может быть пустым. Напишите текст:", + text="❗ Комментарий не может быть пустым. Напишите текст или прикрепите файл:", attachments=[menu_only_kb()], ) return @@ -453,10 +503,19 @@ async def on_comment_text( 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 "📎 Файл прикреплён" + ok = await add_task_comment( task_number=task_number, - text=text, + text=comment_text, contact_person_id=contact_person_id, + attachments=file_atts or None, ) if ok: @@ -484,10 +543,11 @@ async def on_comment_text( text="⚠️ Не удалось отправить комментарий. Попробуйте позже.", ) - await bot_send( + menu_mid = await bot_send( event.bot, chat_id=chat_id, user_id=user_id, text=await build_main_menu_text(bot_user), attachments=[main_menu_kb()], ) + await nav_set(context, menu_mid) diff --git a/bot/keyboards.py b/bot/keyboards.py index 0239aa7..6dc19f5 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -47,6 +47,7 @@ def done_ticket_kb() -> Attachment: return ( InlineKeyboardBuilder() .row(CallbackButton(text="✅ Готово", payload="done_ticket")) + .row(CallbackButton(text="❌ Отменить создание", payload="cancel_ticket")) .as_markup() ) @@ -146,6 +147,16 @@ def comment_input_kb(task_id: int) -> Attachment: ) +def notification_kb() -> Attachment: + """Клавиатура под уведомлением о новом комментарии.""" + return ( + InlineKeyboardBuilder() + .row(CallbackButton(text="📋 Мои заявки", payload="view_tickets")) + .row(CallbackButton(text="🏠 Главное меню", payload="main_menu")) + .as_markup() + ) + + def ticket_detail_kb(page: int = 0) -> Attachment: return ( InlineKeyboardBuilder() diff --git a/bot/nav_helper.py b/bot/nav_helper.py new file mode 100644 index 0000000..1ced761 --- /dev/null +++ b/bot/nav_helper.py @@ -0,0 +1,110 @@ +""" +Утилиты навигационных переходов. + +Логика: + - Если кнопка нажата на последнем навигационном сообщении (nav_mid в контексте), + редактируем его на месте (edit-in-place). + - Иначе удаляем триггерное сообщение и отправляем новое в конец чата. + +nav_mid сохраняется в контексте и обновляется при каждом nav_finish. +""" +from __future__ import annotations + +from maxapi.context import BaseContext +from maxapi.types.updates.message_callback import MessageCallback + +from bot.send_helper import bot_send + + +async def nav_prepare( + event: MessageCallback, + context: BaseContext, + loading_text: str, + chat_id: int | None, + user_id: int, +) -> str | None: + """ + Начать навигационный переход: показать индикатор загрузки. + + Возвращает mid, который нужно передать в nav_finish. + """ + trigger_mid = event.message.body.mid if (event.message and event.message.body) else None + data = await context.get_data() + nav_mid = data.get("nav_mid") + + if trigger_mid and trigger_mid == nav_mid: + # Это последнее навигационное сообщение — редактируем на месте + try: + await event.bot.edit_message( + message_id=trigger_mid, + text=loading_text, + attachments=[], + ) + return trigger_mid + except Exception: + pass # fallthrough: отправим новое + + # Старое сообщение (или nav_mid не совпадает) → удаляем и отправляем новое + if trigger_mid: + try: + await event.bot.delete_message(message_id=trigger_mid) + except Exception: + pass + + return await bot_send( + event.bot, + chat_id=chat_id, + user_id=user_id, + text=loading_text, + attachments=[], + ) + + +async def nav_finish( + bot, + context: BaseContext, + mid: str | None, + chat_id: int | None, + user_id: int, + text: str, + attachments: list, +) -> str | None: + """ + Завершить навигационный переход: вставить финальный контент в mid. + При неудаче редактирования — отправляет новое сообщение. + Всегда обновляет nav_mid в контексте. + """ + result_mid: str | None = None + + if mid: + try: + await bot.edit_message( + message_id=mid, + text=text, + attachments=attachments or [], + ) + result_mid = mid + except Exception: + pass + + if not result_mid: + result_mid = await bot_send( + bot, + chat_id=chat_id, + user_id=user_id, + text=text, + attachments=attachments, + ) + + # Обновляем nav_mid, сохраняя остальные данные контекста + data = await context.get_data() + data["nav_mid"] = result_mid + await context.set_data(data) + return result_mid + + +async def nav_set(context: BaseContext, mid: str | None) -> None: + """Сохранить nav_mid после ручной отправки навигационного сообщения.""" + data = await context.get_data() + data["nav_mid"] = mid + await context.set_data(data) diff --git a/bot/notifier.py b/bot/notifier.py new file mode 100644 index 0000000..6efdd07 --- /dev/null +++ b/bot/notifier.py @@ -0,0 +1,173 @@ +""" +Фоновый опрос Intradesk API для push-уведомлений о новых комментариях. + +Алгоритм за один цикл: + 1. Один запрос — все открытые задачи по всем клиентам сразу. + 2. Группируем по clientid в Python — никакого цикла запросов по клиентам. + 3. Для задач с изменившимся updatedat — запрашиваем историю (1 запрос на задачу). + 4. Нашли новый комментарий → уведомляем всех пользователей организации. + 5. Задачи, исчезнувшие из открытых — убираем из слежения. + +Первая встреча с задачей: запоминаем updatedat как базовую линию, историю не смотрим. +Первое изменение updatedat: устанавливаем comment-baseline, не уведомляем. +Последующие изменения: проверяем и уведомляем. +""" +from __future__ import annotations + +import asyncio +import logging +from collections import defaultdict + +import config +from database.queries.bot_users import get_all_bot_users +from database.queries.task_watch import ( + get_task_watch, + get_watched_clients, + remove_tasks_not_in, + upsert_task_watch, +) +from external.intradesk_api import ( + fetch_all_open_tasks_lightweight, + get_task_comments, + invalidate_task_comment, +) + +logger = logging.getLogger(__name__) + + +async def _send_notification(bot, chat_id: int, task_number: int, comment: dict) -> None: + from bot.keyboards import notification_kb + + ctext = comment["text"] + if len(ctext) > 200: + ctext = ctext[:200] + "…" + + text = ( + f"💬 Новый ответ по заявке #{task_number}\n\n" + f"«{ctext}»\n" + f"— {comment['author']}, {comment['date']}" + ) + await bot.send_message( + chat_id=chat_id, + text=text, + attachments=[notification_kb()], + ) + + +async def _process_task(task: dict, org_id: int, bot) -> None: + """Проверить одну задачу и при необходимости отправить уведомление.""" + tid = task["id"] + task_number = task["task_number"] + client_id = task["client_id"] + updatedat = task["updatedat"] + + watch = await get_task_watch(tid) + + if watch is None: + # Новая задача — запоминаем updatedat, history не трогаем + await upsert_task_watch(tid, client_id, task_number, updatedat, "") + logger.debug("task_watch new: task=%s client=%s", tid, client_id) + return + + if updatedat == watch["last_updatedat"]: + return # Ничего не изменилось — history не трогаем + + # updatedat изменился — только теперь идём в history API + last_known = watch["last_comment_date"] + + invalidate_task_comment(tid) + comments = await get_task_comments(tid, count=10) + + if not last_known: + # Первое изменение с момента начала слежения — устанавливаем baseline + baseline = comments[0]["date"] if comments else "" + await upsert_task_watch(tid, client_id, task_number, updatedat, baseline) + return + + new_comments = [c for c in comments if c["date"] > last_known] + + if new_comments: + latest = new_comments[0] + users = await get_all_bot_users(is_authorized=1, is_blocked=0, org_id=org_id) + notified = 0 + for user in users: + chat_id = user.get("max_chat_id") + if not chat_id: + continue + try: + await _send_notification(bot, chat_id, task_number, latest) + notified += 1 + except Exception as exc: + logger.warning( + "notify failed user=%s task=%s: %s", + user.get("max_user_id"), task_number, exc, + ) + logger.info( + "notifier: task=%s (#%s) new comment → notified %d users", + tid, task_number, notified, + ) + await upsert_task_watch(tid, client_id, task_number, updatedat, latest["date"]) + else: + # updatedat изменился (смена статуса и т.п.), новых комментариев нет + await upsert_task_watch(tid, client_id, task_number, updatedat, last_known) + + +async def _poll_once(bot) -> None: + # Один запрос — все открытые задачи + all_tasks = await fetch_all_open_tasks_lightweight() + + # Группируем по clientid прямо в Python + tasks_by_client: dict[int, list[dict]] = defaultdict(list) + for task in all_tasks: + tasks_by_client[task["client_id"]].append(task) + + # Маппинг intradesk_id → org_id из нашей БД + clients = await get_watched_clients() + client_to_org = {c["intradesk_id"]: c["org_id"] for c in clients} + + logger.debug( + "notifier poll: %d tasks across %d clients", + len(all_tasks), len(tasks_by_client), + ) + + # Обрабатываем задачи по клиентам + for client_id, tasks in tasks_by_client.items(): + org_id = client_to_org.get(client_id) + if org_id is None: + continue # клиент есть в Intradesk, но не привязан к нашей БД + + task_ids_seen = {t["id"] for t in tasks} + + for task in tasks: + try: + await _process_task(task, org_id, bot) + except Exception as exc: + logger.exception( + "notifier error task=%s client=%s: %s", task["id"], client_id, exc + ) + + # Убираем из слежения задачи, пропавшие из открытых + await remove_tasks_not_in(client_id, task_ids_seen) + + # Клиенты из нашей БД, у которых сейчас 0 открытых задач + for c in clients: + if c["intradesk_id"] not in tasks_by_client: + await remove_tasks_not_in(c["intradesk_id"], set()) + + +async def run_notifier(bot) -> None: + """Запустить фоновый опрос. Добавлен в asyncio.gather в main.py.""" + interval = config.NOTIFICATION_POLL_INTERVAL + if not interval or interval <= 0: + logger.info("Notifier disabled (NOTIFICATION_POLL_INTERVAL=0)") + return + + logger.info("Notifier started, interval=%ds", interval) + # Пауза перед первым прогоном — даём боту и БД время инициализироваться + await asyncio.sleep(30) + while True: + try: + await _poll_once(bot) + except Exception as exc: + logger.exception("notifier _poll_once failed: %s", exc) + await asyncio.sleep(interval) diff --git a/config.py b/config.py index cb61ad9..981c99a 100644 --- a/config.py +++ b/config.py @@ -40,6 +40,8 @@ INTRADESK_DEFAULT_SERVICE: str = os.environ.get("INTRADESK_DEFAULT_SERVICE", "") INTRADESK_DEFAULT_TASK_TYPE: str = os.environ.get("INTRADESK_DEFAULT_TASK_TYPE", "") # ID статуса «Выполнена» для кнопки «Закрыть заявку» (Настройки → Справочники → Статусы → ID) INTRADESK_CLOSE_STATUS_ID: str = os.environ.get("INTRADESK_CLOSE_STATUS_ID", "") +# Интервал опроса Intradesk для push-уведомлений, секунды (0 — отключить) +NOTIFICATION_POLL_INTERVAL: int = int(os.environ.get("NOTIFICATION_POLL_INTERVAL", "120")) # ID группы исполнителей для фильтрации заявок INTRADESK_EXECUTOR_GROUP: str = os.environ.get("INTRADESK_EXECUTOR_GROUP", "") # Список ID исполнителей через запятую (доп. фильтр) diff --git a/data/support.db b/data/support.db index bd84232..915a170 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 a946236..34b6766 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 07132dc..17a6c7d 100644 Binary files a/data/support.db-wal and b/data/support.db-wal differ diff --git a/database/connection.py b/database/connection.py index 9eb248f..a091fac 100644 --- a/database/connection.py +++ b/database/connection.py @@ -45,6 +45,21 @@ async def init_db() -> None: except Exception: pass # колонка уже существует + # Таблица слежения за задачами для push-уведомлений + await _db.execute(""" + CREATE TABLE IF NOT EXISTS task_watch ( + task_id INTEGER PRIMARY KEY, + client_id INTEGER NOT NULL, + task_number INTEGER, + last_updatedat TEXT NOT NULL DEFAULT '', + last_comment_date TEXT NOT NULL DEFAULT '' + ) + """) + await _db.execute( + "CREATE INDEX IF NOT EXISTS idx_task_watch_client ON task_watch(client_id)" + ) + await _db.commit() + def get_db() -> aiosqlite.Connection: if _db is None: diff --git a/database/queries/task_watch.py b/database/queries/task_watch.py new file mode 100644 index 0000000..02a2855 --- /dev/null +++ b/database/queries/task_watch.py @@ -0,0 +1,60 @@ +"""Запросы к таблице task_watch — слежение за задачами для push-уведомлений.""" +from database.connection import get_db + + +async def get_watched_clients() -> list[dict]: + """Все организации с привязанным intradesk_id.""" + db = get_db() + async with db.execute( + "SELECT id AS org_id, intradesk_id FROM organizations WHERE intradesk_id IS NOT NULL" + ) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + + +async def get_task_watch(task_id: int) -> dict | None: + """Получить состояние слежения для одной задачи.""" + db = get_db() + async with db.execute( + "SELECT * FROM task_watch WHERE task_id = ?", (task_id,) + ) as cur: + row = await cur.fetchone() + return dict(row) if row else None + + +async def upsert_task_watch( + task_id: int, + client_id: int, + task_number: int | None, + last_updatedat: str, + last_comment_date: str, +) -> None: + """Создать или обновить запись слежения.""" + db = get_db() + await db.execute( + """ + INSERT INTO task_watch (task_id, client_id, task_number, last_updatedat, last_comment_date) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(task_id) DO UPDATE SET + last_updatedat = excluded.last_updatedat, + last_comment_date = excluded.last_comment_date, + task_number = COALESCE(excluded.task_number, task_number) + """, + (task_id, client_id, task_number, last_updatedat, last_comment_date), + ) + await db.commit() + + +async def remove_tasks_not_in(client_id: int, task_ids: set[int]) -> None: + """Удалить закрытые/архивные задачи клиента из слежения.""" + db = get_db() + if task_ids: + placeholders = ",".join("?" * len(task_ids)) + await db.execute( + f"DELETE FROM task_watch WHERE client_id = ? AND task_id NOT IN ({placeholders})", + (client_id, *task_ids), + ) + else: + # Все задачи закрыты — убираем всё по клиенту + await db.execute("DELETE FROM task_watch WHERE client_id = ?", (client_id,)) + await db.commit() diff --git a/external/intradesk_api.py b/external/intradesk_api.py index fe05926..d1b73d0 100644 --- a/external/intradesk_api.py +++ b/external/intradesk_api.py @@ -12,6 +12,7 @@ Intradesk REST API: создание заявок и чтение истории """ from __future__ import annotations +import asyncio import json import logging import re @@ -91,6 +92,127 @@ def invalidate_task_comment(task_id: int) -> None: logger.debug("cache invalidated for task_id=%s", task_id) +# ── Загрузка файлов ─────────────────────────────────────────────────────────── + +def _default_filename(att_type: str) -> str: + t = str(att_type).upper() + if "IMAGE" in t or "PHOTO" in t: + return "image.jpg" + if "VIDEO" in t: + return "video.mp4" + if "AUDIO" in t: + return "audio.wav" + return "file.bin" + + +async def upload_file_to_intradesk( + file_bytes: bytes, + filename: str, + content_type: str, + task_id: int = 0, + target: str = "Description", +) -> dict | None: + """ + Загрузить файл в Intradesk. + + task_id=0 — для новой заявки (описание). + task_id=N — для существующей заявки (комментарий). + target — "Description" (30) или "Comment" (20). + + Возвращает metadata файла {"id", "name", "contentType", ...} или None. + """ + key = config.INTRADESK_CHANGES_API_KEY + if not key: + logger.warning("upload_file_to_intradesk: INTRADESK_CHANGES_API_KEY не задан") + return None + + url = ( + f"{config.INTRADESK_BASE_URL.rstrip('/')}" + f"/files/api/tasks/{task_id}/files/target/{target}" + f"?ApiKey={key}" + ) + + if config.DRY_RUN: + print( + f"\n[DRY-RUN] upload_file_to_intradesk — {filename} ({len(file_bytes)} bytes)\n" + f" POST {url}\n" + ) + return None + + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + url, + files={"file": (filename, file_bytes, content_type)}, + timeout=30.0, + ) + if resp.status_code not in (200, 201): + logger.error( + "upload_file_to_intradesk → HTTP %s: %s", + resp.status_code, resp.text[:300], + ) + return None + data = resp.json() + meta = data[0] if isinstance(data, list) and data else data + if isinstance(meta, dict): + logger.info("upload_file_to_intradesk ok: %s → id=%s", filename, meta.get("id")) + return meta if isinstance(meta, dict) else None + except Exception as exc: + logger.exception("upload_file_to_intradesk error: %s", exc) + return None + + +async def download_and_upload( + file_url: str, + filename: str | None, + content_type_hint: str | None, + task_id: int = 0, + target: str = "Description", +) -> dict | None: + """Скачать файл из MAX CDN и загрузить в Intradesk.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.get(file_url, timeout=30.0, follow_redirects=True) + if resp.status_code != 200: + logger.warning("download_and_upload: GET %s → HTTP %s", file_url, resp.status_code) + return None + file_bytes = resp.content + ct_raw = resp.headers.get("content-type", content_type_hint or "application/octet-stream") + ct = ct_raw.split(";")[0].strip() + fname = filename or "attachment" + except Exception as exc: + logger.warning("download_and_upload download error: %s", exc) + return None + + return await upload_file_to_intradesk(file_bytes, fname, ct, task_id=task_id, target=target) + + +async def upload_attachments( + attachments: list[dict], + task_id: int = 0, + target: str = "Description", +) -> list[dict]: + """ + Скачать и загрузить все вложения в Intradesk параллельно. + + Возвращает те же вложения, обогащённые полем ``intradesk_meta``. + Если загрузка файла не удалась — поле будет None, вложение остаётся в списке. + """ + async def _process(att: dict) -> dict: + url = att.get("url") + if not url: + return att + filename = att.get("filename") or _default_filename(att.get("type", "")) + meta = await download_and_upload(url, filename, None, task_id=task_id, target=target) + return {**att, "intradesk_meta": meta} + + results = await asyncio.gather(*[_process(a) for a in attachments], return_exceptions=True) + enriched = [] + for orig, result in zip(attachments, results): + enriched.append(result if isinstance(result, dict) else orig) + return enriched + + # ── API-функции ─────────────────────────────────────────────────────────────── async def get_task_last_comment(task_id: int) -> dict | None: @@ -371,6 +493,48 @@ async def get_client_open_tasks( return result +async def fetch_all_open_tasks_lightweight() -> list[dict]: + """ + Получить ВСЕ открытые задачи (по всем клиентам) одним запросом. + + Используется нотифайером — без кэша, без комментариев. + Возвращает [{id, task_number, client_id, updatedat}]. + Нотифайер сам группирует результат по client_id. + """ + api_key = config.INTRADESK_TASKS_API_KEY + if not api_key: + return [] + + url = ( + f"{config.INTRADESK_BASE_URL.rstrip('/')}/tasklist/odata/v3/tasks" + f"?ApiKey={api_key}" + f"&$filter=(closedat eq null) and (isarchived eq false)" + f"&$orderby=updatedat desc" + f"&$top=500" + ) + try: + async with httpx.AsyncClient() as client: + resp = await client.get(url, timeout=30.0) + if resp.status_code != 200: + logger.warning("fetch_all_open_tasks_lightweight → HTTP %s", resp.status_code) + return [] + data = resp.json() + except Exception as exc: + logger.warning("fetch_all_open_tasks_lightweight error: %s", exc) + return [] + + return [ + { + "id": t.get("id"), + "task_number": t.get("tasknumber"), + "client_id": t.get("clientid"), + "updatedat": t.get("updatedat") or "", + } + for t in (data.get("value") or []) + if t.get("id") and t.get("clientid") + ] + + def _extract_last_comment_from_lifetime(lifetime: dict | None) -> dict | None: """Извлечь последний комментарий из поля lifetime задачи.""" if not lifetime: @@ -391,10 +555,14 @@ async def add_task_comment( task_number: int, text: str, contact_person_id: int | None = None, + attachments: list[dict] | None = None, ) -> bool: """ Добавить комментарий к заявке (PUT /changes/v3/tasks). + attachments — список вложений с полем ``intradesk_meta`` (заполняется + через upload_attachments с target="Comment" перед вызовом этой функции). + Возвращает True при успехе, False при ошибке. Кэш задачи сбрасывается вызывающей стороной через invalidate_task_comment. """ @@ -406,6 +574,12 @@ async def add_task_comment( blocks: dict[str, str] = { "comment": json.dumps({"value": text}, ensure_ascii=False), } + add_files = [a["intradesk_meta"] for a in (attachments or []) if a.get("intradesk_meta")] + if add_files: + blocks["attachments"] = json.dumps( + {"value": {"addFiles": add_files, "deleteFileIds": []}}, + ensure_ascii=False, + ) body = {"number": task_number, "blocks": blocks, "Channel": "api"} url = f"{config.INTRADESK_BASE_URL.rstrip('/')}/changes/v3/tasks?ApiKey={key}" if contact_person_id is not None: @@ -499,11 +673,14 @@ async def create_intradesk_task( description: str, client_intradesk_id: int | None = None, contact_person_id: int | None = None, - attachment_urls: list[str] | None = None, + attachments: list[dict] | None = None, ) -> dict | None: """ Создать заявку в Intradesk. + attachments — список вложений с полем ``intradesk_meta`` (заполняется + через upload_attachments с target="Description" перед вызовом этой функции). + Возвращает ответ API (dict с полями id, tasknumber и др.) или None при ошибке. Кэш клиента сбрасывается вызывающей стороной через invalidate_client_cache. """ @@ -512,14 +689,16 @@ async def create_intradesk_task( logger.warning("create_intradesk_task: INTRADESK_CHANGES_API_KEY не задан") return None - full_desc = description - if attachment_urls: - full_desc += "\n\nВложения:\n" + "\n".join(attachment_urls) - blocks: dict[str, str] = { "name": json.dumps({"value": name}, ensure_ascii=False), - "description": json.dumps({"value": full_desc}, ensure_ascii=False), + "description": json.dumps({"value": description}, ensure_ascii=False), } + add_files = [a["intradesk_meta"] for a in (attachments or []) if a.get("intradesk_meta")] + if add_files: + blocks["attachments"] = json.dumps( + {"value": {"addFiles": add_files, "deleteFileIds": []}}, + ensure_ascii=False, + ) if client_intradesk_id is not None and contact_person_id is not None: blocks["initiator"] = json.dumps( {"value": {"groupid": client_intradesk_id, "userid": contact_person_id}}, diff --git a/main.py b/main.py index 83719d6..0dc8989 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ setup_logging() # до всех остальных импортов from database.connection import init_db from bot.setup import create_bot_and_dispatcher +from bot.notifier import run_notifier from admin.app import create_admin_app from maxapi.types.command import BotCommand import config @@ -50,6 +51,7 @@ async def main() -> None: await asyncio.gather( dp.start_polling(bot, skip_updates=True), server.serve(), + run_notifier(bot), ) diff --git a/run.bat b/run.bat index 7fd0139..eb2ad23 100644 --- a/run.bat +++ b/run.bat @@ -1,6 +1,3 @@ -echo [INFO] Запуск... Admin-панель: http://localhost:8080/admin/ -echo. - python main.py if %ERRORLEVEL% neq 0 (