diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 15bae42..dd0d9ef 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,9 @@ "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 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\\)\\)\")" + "Bash(python -c \"from maxapi.context import State; s = State\\(\\); print\\(type\\(s\\), dir\\(s\\)\\)\")", + "Bash(python -c \"import maxapi; print\\(maxapi.__file__\\)\")", + "Bash(find C:UsersProfessionalAppDataLocalProgramsPythonPython313Libsite-packagesmaxapi -name command.py -o -name *command*)" ] } } diff --git a/.env b/.env index a48ce5e..d281505 100644 --- a/.env +++ b/.env @@ -23,3 +23,4 @@ INTRADESK_CHANGES_API_KEY=26221B839B6643E68345FD26BF INTRADESK_DEFAULT_SERVICE=84681/84683/ # ID типа заявки (Настройки → Заявки → Типы заявок → ID) INTRADESK_DEFAULT_TASK_TYPE=21026 +INTRADESK_CLOSE_STATUS_ID=68045 \ No newline at end of file diff --git a/bot/handlers/view_tickets.py b/bot/handlers/view_tickets.py index 1dbcaa7..154eaf2 100644 --- a/bot/handlers/view_tickets.py +++ b/bot/handlers/view_tickets.py @@ -18,8 +18,9 @@ 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.keyboards import iticket_detail_kb, main_menu_kb, menu_only_kb +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.states import CommentStates from database.connection import get_db @@ -27,7 +28,9 @@ 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, @@ -218,6 +221,23 @@ async def cb_iticket_detail( await event.answer(notification="") + # Если пришли сюда из комментарийного флоу (кнопка «Назад») — + # удаляем промпт-сообщение и сбрасываем state, сохраняя кэш задач. + state = await context.get_state() + 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 context.set_state(None) + await context.set_data({ + "tasks": d.get("tasks"), + "page": d.get("page"), + "total": d.get("total"), + }) + # Ищем задачу в кэше контекста (загружена при показе списка) data = await context.get_data() task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None) @@ -244,28 +264,104 @@ async def cb_iticket_detail( if task.get("name"): lines += ["", f"📄 {task['name']}"] - # Последний комментарий — отдельный запрос к history API - comment = await get_task_last_comment(task["id"]) - if comment and comment.get("text"): - ctext = comment["text"] - if len(ctext) > 500: - ctext = ctext[:500] + "…" - lines += [ - "", - f"💭 Последний комментарий ({comment['date']}):", - f" {ctext}", - f" — {comment['author']}", - ] + if task.get("description"): + desc = task["description"] + if len(desc) > 600: + desc = desc[:600] + "…" + lines += ["", "📝 Описание:", desc] + + # Последние 10 комментариев из history API + comments = await get_task_comments(task["id"], count=10) + if comments: + lines += ["", f"💬 Комментарии ({len(comments)}):"] + for c in comments: + ctext = c["text"] + if len(ctext) > 300: + ctext = ctext[:300] + "…" + lines += [ + "", + f"─" * 16, + f"{c['date']} — {c['author']}", + ctext, + ] + else: + lines += ["", "💬 Комментариев нет"] await bot_reply( event, text="\n".join(lines), - attachments=[iticket_detail_kb(task_id=task["id"])], + 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, ) +# ── Закрыть заявку ──────────────────────────────────────────────────────────── + +@router.message_callback(PayloadStartsWith("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="Ошибка") + 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 + + if not task_number: + 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 + + # Получаем 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 bot_reply( + event, + 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, + 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, + ) + + # ── Оставить комментарий ────────────────────────────────────────────────────── @router.message_callback(PayloadStartsWith("iticket_comment:")) @@ -280,7 +376,7 @@ async def cb_add_comment( await event.answer(notification="") - # Ищем task_number в кэше контекста + # Ищем 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 @@ -290,6 +386,10 @@ async def cb_add_comment( "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 @@ -303,9 +403,9 @@ async def cb_add_comment( user_id=bot_user["max_user_id"], text=( f"💬 Напишите комментарий{num_str}:\n\n" - "Для отмены нажмите «🏠 Главное меню»." + "Отправьте текстовое сообщение или нажмите «← Назад к заявке» для отмены." ), - attachments=[menu_only_kb()], + attachments=[comment_input_kb(task_id)], ) # Сохраняем mid для возможной очистки if mid: diff --git a/bot/keyboards.py b/bot/keyboards.py index d5f86bd..0239aa7 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -118,7 +118,7 @@ def intradesk_tasks_list_kb( return builder.as_markup() -def iticket_detail_kb(task_id: int | None = None) -> Attachment: +def iticket_detail_kb(task_id: int | None = None, can_close: bool = False) -> Attachment: """Клавиатура внутри карточки Intradesk-задачи.""" builder = InlineKeyboardBuilder() if task_id is not None: @@ -126,11 +126,26 @@ def iticket_detail_kb(task_id: int | None = None) -> Attachment: text="💬 Оставить комментарий", payload=f"iticket_comment:{task_id}", )) + if task_id is not None and can_close: + builder.row(CallbackButton( + text="🔒 Закрыть заявку", + payload=f"iticket_close:{task_id}", + )) builder.row(CallbackButton(text="← К списку заявок", payload="itickets_page:0")) builder.row(CallbackButton(text="🏠 Главное меню", payload="main_menu")) return builder.as_markup() +def comment_input_kb(task_id: int) -> Attachment: + """Клавиатура при вводе комментария — кнопка назад и меню.""" + return ( + InlineKeyboardBuilder() + .row(CallbackButton(text="← Назад к заявке", payload=f"iticket_detail:{task_id}")) + .row(CallbackButton(text="🏠 Главное меню", payload="main_menu")) + .as_markup() + ) + + def ticket_detail_kb(page: int = 0) -> Attachment: return ( InlineKeyboardBuilder() diff --git a/config.py b/config.py index ef27343..cb61ad9 100644 --- a/config.py +++ b/config.py @@ -38,6 +38,8 @@ INTRADESK_CHANGES_API_KEY: str = os.environ.get("INTRADESK_CHANGES_API_KEY", "") INTRADESK_DEFAULT_SERVICE: str = os.environ.get("INTRADESK_DEFAULT_SERVICE", "") # ID типа заявки по умолчанию (Настройки → Заявки → Типы заявок → ID) 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", "") # ID группы исполнителей для фильтрации заявок INTRADESK_EXECUTOR_GROUP: str = os.environ.get("INTRADESK_EXECUTOR_GROUP", "") # Список ID исполнителей через запятую (доп. фильтр) diff --git a/data/support.db-shm b/data/support.db-shm index bc52efb..a946236 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 7fafbd0..07132dc 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 342cc64..fe05926 100644 --- a/external/intradesk_api.py +++ b/external/intradesk_api.py @@ -87,6 +87,7 @@ def invalidate_client_cache(client_id: int) -> None: def invalidate_task_comment(task_id: int) -> None: """Сбросить кэш истории конкретной задачи после добавления комментария.""" _cache.pop(f"task_comment:{task_id}", None) + _cache_delete_prefix(f"task_comments:{task_id}:") logger.debug("cache invalidated for task_id=%s", task_id) @@ -144,6 +145,56 @@ async def get_task_last_comment(task_id: int) -> dict | None: return result +async def get_task_comments(task_id: int, count: int = 10) -> list[dict]: + """ + Получить последние комментарии к заявке. + + Возвращает список {text, author, date}, до count штук, новые сначала. + Результат кэшируется на _TTL_TASK_COMMENT секунд. + """ + ck = f"task_comments:{task_id}:{count}" + hit, cached = _cache_get(ck) + if hit: + return cached + + api_key = config.INTRADESK_TASKS_API_KEY + if not api_key: + return [] + + url = ( + f"{config.INTRADESK_BASE_URL.rstrip('/')}" + f"/taskhistory/api/v2.0/lifetime/{task_id}/full" + f"?ApiKey={api_key}&sortDirection=Desc&top={count * 3}&events=50" + ) + try: + async with httpx.AsyncClient() as client: + resp = await client.get(url, timeout=15.0) + if resp.status_code != 200: + logger.warning("get_task_comments task=%s → HTTP %s", task_id, resp.status_code) + return [] + data = resp.json() + except Exception as exc: + logger.warning("get_task_comments error: %s", exc) + return [] + + result: list[dict] = [] + for entry in data.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 "" + result.append({ + "text": _strip_html(ev.get("stringvalue") or ""), + "author": entry.get("username") or "—", + "date": raw_date[:16].replace("T", " "), + }) + break # одна запись = одно событие комментария + if len(result) >= count: + break + + _cache_set(ck, result, _TTL_TASK_COMMENT) + return result + + async def count_client_open_tasks(client_id: int) -> int: """ Количество незакрытых заявок клиента. @@ -297,6 +348,7 @@ async def get_client_open_tasks( "id": t.get("id"), "task_number": t.get("tasknumber"), "name": t.get("name") or "", + "description": _strip_html(t.get("description") or ""), "status_name": dicts_map.get(status_id, "") if status_id else "", "created_at": t.get("createdat") or "", "updated_at": t.get("updatedat") or "", @@ -389,6 +441,59 @@ async def add_task_comment( return False +async def close_task( + task_number: int, + contact_person_id: int | None = None, +) -> bool: + """ + Закрыть заявку — установить статус INTRADESK_CLOSE_STATUS_ID (PUT /changes/v3/tasks). + + Возвращает True при успехе, False при ошибке или незаданном статусе. + """ + status_id = config.INTRADESK_CLOSE_STATUS_ID + if not status_id: + logger.warning("close_task: INTRADESK_CLOSE_STATUS_ID не задан") + return False + + key = config.INTRADESK_CHANGES_API_KEY + if not key: + logger.warning("close_task: INTRADESK_CHANGES_API_KEY не задан") + return False + + blocks: dict[str, str] = { + "status": json.dumps({"value": int(status_id)}, 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: + url += f"&userid={contact_person_id}" + + if config.DRY_RUN: + print( + "\n[DRY-RUN] close_task — запрос НЕ отправлен\n" + f" PUT {url}\n" + f" Body: {json.dumps(body, ensure_ascii=False, indent=2)}\n" + ) + return False + + try: + async with httpx.AsyncClient() as client: + resp = await client.put( + url, + json=body, + headers={"Content-Type": "application/json"}, + timeout=20.0, + ) + if resp.status_code not in (200, 201): + logger.error("close_task → HTTP %s: %s", resp.status_code, resp.text[:500]) + return False + logger.info("close_task ok: task_number=%s", task_number) + return True + except Exception as exc: + logger.exception("close_task error: %s", exc) + return False + + async def create_intradesk_task( name: str, description: str, diff --git a/main.py b/main.py index 60e1cf2..83719d6 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ setup_logging() # до всех остальных импортов from database.connection import init_db from bot.setup import create_bot_and_dispatcher from admin.app import create_admin_app +from maxapi.types.command import BotCommand import config @@ -23,6 +24,18 @@ async def main() -> None: await init_db() bot, dp = create_bot_and_dispatcher() + + # Регистрируем команды — появляются в меню кнопки "/" в поле ввода + try: + await bot.set_my_commands( + BotCommand(name="start", description="Главное меню"), + BotCommand(name="menu", description="Показать меню"), + BotCommand(name="cancel", description="Отменить текущее действие"), + ) + except Exception as e: + import logging + logging.getLogger(__name__).warning("set_my_commands failed: %s", e) + admin_app = create_admin_app() server = uvicorn.Server(