diff --git a/bot/handlers/create_ticket.py b/bot/handlers/create_ticket.py index b966c58..ef9422a 100644 --- a/bot/handlers/create_ticket.py +++ b/bot/handlers/create_ticket.py @@ -44,12 +44,13 @@ async def cleanup_ticket_flow(bot, context: BaseContext) -> None: Вызывается из любого обработчика, который прерывает этот поток (главное меню, список заявок, повторное нажатие «Создать заявку»). """ - from bot.states import TicketStates # avoid circular at module level + from bot.states import TicketStates, CommentStates # avoid circular at module level state = await context.get_state() creation_states = [ TicketStates.waiting_description, TicketStates.collecting_attachments, TicketStates.confirming, + CommentStates.waiting_text, ] if state not in creation_states: return diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py index 132758e..f49f013 100644 --- a/bot/handlers/menu.py +++ b/bot/handlers/menu.py @@ -1,47 +1,79 @@ """Обработчик главного меню.""" +import asyncio + +from maxapi.context import BaseContext from maxapi.dispatcher import Router from maxapi.filters import F from maxapi.types.updates.message_callback import MessageCallback -from maxapi.context import BaseContext from bot.keyboards import main_menu_kb from bot.send_helper import bot_reply -from database.connection import get_db -from database.queries.intradesk import ( - count_active_tasks_for_local_org, - count_tasks_for_local_org, -) from database.queries.organizations import get_organization +from external.intradesk_api import count_client_open_tasks, get_client_last_comment router = Router(router_id="menu") async def build_main_menu_text(bot_user: dict) -> str: - """Формирует текст главного меню с именем объекта и статистикой заявок.""" + """Формирует текст главного меню — данные берутся из Intradesk API.""" org_id = bot_user.get("org_id") org_name: str | None = None + intradesk_id: int | None = None if org_id: org = await get_organization(org_id) org_name = org["name"] if org else None + intradesk_id = org.get("intradesk_id") if org else None lines: list[str] = [] - if org_name: - lines.append(f"🏢 {org_name}") - else: - lines.append("🏠 Главное меню") + lines.append(f"🏢 {org_name}" if org_name else "🏠 Поддержка") + lines.append("━" * 16) - if org_id: - db = get_db() - total = await count_tasks_for_local_org(db, org_id) - active = await count_active_tasks_for_local_org(db, org_id) + if intradesk_id: + # Счётчик активных и последний комментарий — параллельно + active, last_comment = await asyncio.gather( + count_client_open_tasks(intradesk_id), + get_client_last_comment(intradesk_id), + ) - if total == 0: - lines.append("Заявок пока нет.") + if active == 0: + lines += [ + "📭 Активных заявок нет", + "", + "Нажмите «Создать заявку» и мы", + "свяжемся с вами в ближайшее время.", + ] else: - lines.append(f"Заявок: {total} (активных: {active})") + lines.append(f"📋 Активных заявок: {active}") - lines.append("\nВыберите действие:") + if last_comment and last_comment.get("text"): + ctext = last_comment["text"] + 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: + y, m, d = date_part.split("-") + short_date = f"{d}.{m}" + (f" {time_part}" if time_part else "") + else: + short_date = raw_date + author = (last_comment.get("author") or "").strip() + task_num = last_comment.get("task_number") + num_str = f" (заявка #{task_num})" if task_num else "" + lines += [ + "", + f"💬 Последний ответ{num_str}:", + f"«{ctext}»", + f"— {author}, {short_date}", + ] + else: + lines += ["", "💬 Новых ответов нет"] + else: + lines.append("📭 Нет данных об организации") + + lines += ["", "━" * 16, "Выберите действие 👇"] return "\n".join(lines) diff --git a/bot/handlers/start.py b/bot/handlers/start.py index 91aa192..1b001dd 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -68,11 +68,13 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None: if bot_user["is_authorized"]: logger.info("✅ bot_started returning user | user_id=%s", user.user_id) 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( event.bot, chat_id=event.chat_id, user_id=user.user_id, - text="👋 С возвращением! Выберите действие:", + text=menu_text, attachments=[main_menu_kb()], ) return @@ -141,11 +143,13 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None: return 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( event.bot, chat_id=chat_id, user_id=user_id, - text="Главное меню:", + text=menu_text, attachments=[main_menu_kb()], ) diff --git a/bot/handlers/view_tickets.py b/bot/handlers/view_tickets.py index efbb693..10041bf 100644 --- a/bot/handlers/view_tickets.py +++ b/bot/handlers/view_tickets.py @@ -19,11 +19,13 @@ from maxapi.filters import F from maxapi.types.updates.message_callback import MessageCallback from bot.filters import PayloadStartsWith -from bot.keyboards import iticket_detail_kb, menu_only_kb -from bot.send_helper import bot_reply +from bot.keyboards import 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 +from database.queries.intradesk import get_intradesk_contact_id_for_phone from database.queries.organizations import get_organization -from external.intradesk_api import get_client_open_tasks, get_task_last_comment +from external.intradesk_api import add_task_comment, get_client_open_tasks, get_task_last_comment router = Router(router_id="view_tickets") @@ -42,19 +44,22 @@ def _status_icon(status_name: str) -> str: def _fmt_date(iso: str) -> str: - """'2026-03-26T09:57:11Z' → '26.03.2026'""" + """'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("-") - return f"{d}.{m}.{y}" + # 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' → '26.03'""" + """'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("-") - return f"{d}.{m}" + 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: @@ -79,9 +84,11 @@ def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str: 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] # только фамилия + 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("💬 Нет комментариев") return "\n".join(lines) @@ -247,7 +254,125 @@ async def cb_iticket_detail( await bot_reply( event, text="\n".join(lines), - attachments=[iticket_detail_kb()], + attachments=[iticket_detail_kb(task_id=task["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:")) +async def cb_add_comment( + 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="") + + # Ищем 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(CommentStates.waiting_text) + await context.set_data({ + "comment_task_id": task_id, + "comment_task_number": task_number, + "flow_message_ids": [], + }) + + 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=( + f"💬 Напишите комментарий{num_str}:\n\n" + "Для отмены нажмите «🏠 Главное меню»." + ), + attachments=[menu_only_kb()], + ) + # Сохраняем mid для возможной очистки + if mid: + d = await context.get_data() + d["flow_message_ids"] = [mid] + await context.set_data(d) + + +@router.message_created(states=CommentStates.waiting_text) +async def on_comment_text( + event: MessageCreated, 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 "" + if not text: + await bot_send( + event.bot, + chat_id=chat_id, + user_id=user_id, + text="❗ Комментарий не может быть пустым. Напишите текст:", + attachments=[menu_only_kb()], + ) + return + + data = await context.get_data() + task_number = data.get("comment_task_number") + + # Сбрасываем состояние до отправки API — защита от повторов + await context.clear() + + # Получаем contact_person_id + contact_person_id: int | None = None + if bot_user.get("phone") and 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: + db = get_db() + contact_person_id = await get_intradesk_contact_id_for_phone( + db, intradesk_id, bot_user["phone"] + ) + + ok = await add_task_comment( + task_number=task_number, + text=text, + contact_person_id=contact_person_id, + ) + + from bot.handlers.menu import build_main_menu_text + + if ok: + num_str = f" к заявке #{task_number}" if task_number else "" + await bot_send( + event.bot, + chat_id=chat_id, + user_id=user_id, + text=f"✅ Комментарий{num_str} успешно отправлен!", + ) + else: + await bot_send( + event.bot, + chat_id=chat_id, + user_id=user_id, + text="⚠️ Не удалось отправить комментарий. Попробуйте позже.", + ) + + 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()], + ) diff --git a/bot/keyboards.py b/bot/keyboards.py index f726010..d5f86bd 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -118,14 +118,17 @@ def intradesk_tasks_list_kb( return builder.as_markup() -def iticket_detail_kb() -> Attachment: +def iticket_detail_kb(task_id: int | None = None) -> Attachment: """Клавиатура внутри карточки Intradesk-задачи.""" - return ( - InlineKeyboardBuilder() - .row(CallbackButton(text="← К списку заявок", payload="itickets_page:0")) - .row(CallbackButton(text="🏠 Главное меню", payload="main_menu")) - .as_markup() - ) + builder = InlineKeyboardBuilder() + if task_id is not None: + builder.row(CallbackButton( + text="💬 Оставить комментарий", + payload=f"iticket_comment:{task_id}", + )) + builder.row(CallbackButton(text="← К списку заявок", payload="itickets_page:0")) + builder.row(CallbackButton(text="🏠 Главное меню", payload="main_menu")) + return builder.as_markup() def ticket_detail_kb(page: int = 0) -> Attachment: diff --git a/bot/states.py b/bot/states.py index a71f9fe..2bcb56c 100644 --- a/bot/states.py +++ b/bot/states.py @@ -9,3 +9,7 @@ class TicketStates(StatesGroup): waiting_description = State() # Ожидаем первое сообщение с описанием collecting_attachments = State() # Накапливаем доп. файлы/текст confirming = State() # Предпросмотр — ждём подтверждения + + +class CommentStates(StatesGroup): + waiting_text = State() # Ожидаем текст комментария к заявке diff --git a/data/support.db-shm b/data/support.db-shm index f99e548..f39aba2 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 eddd421..adba682 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 06b41d9..9ec2a61 100644 --- a/external/intradesk_api.py +++ b/external/intradesk_api.py @@ -9,6 +9,7 @@ from __future__ import annotations import json import logging +import re import httpx @@ -16,6 +17,18 @@ import config logger = logging.getLogger(__name__) +_TAG_RE = re.compile(r"<[^>]+>") + + +def _strip_html(text: str) -> str: + """Убрать HTML-теги и лишние пробелы из текста комментария.""" + if not text: + return text + cleaned = _TAG_RE.sub("", text) + # Схлопнуть множественные пробелы/переносы + cleaned = re.sub(r"\s{2,}", " ", cleaned).strip() + return cleaned + async def get_task_last_comment(task_id: int) -> dict | None: """ @@ -53,13 +66,88 @@ async def get_task_last_comment(task_id: int) -> dict | None: # "2021-03-15T11:54:36.92Z" → "2021-03-15 11:54" short_date = raw_date[:16].replace("T", " ") return { - "text": ev.get("stringvalue") or "", + "text": _strip_html(ev.get("stringvalue") or ""), "author": entry.get("username") or "—", "date": short_date, } return None +async def count_client_open_tasks(client_id: int) -> int: + """Количество незакрытых заявок клиента через API. + + Intradesk не возвращает @odata.count при $top=0, поэтому берём $top=1 + — count в ответе отражает полный итог, а не размер страницы. + """ + key = config.INTRADESK_TASKS_API_KEY + if not key: + return 0 + 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"&$top=1&$count=true" + ) + try: + async with httpx.AsyncClient() as client: + resp = await client.get(url, timeout=10.0) + if resp.status_code != 200: + return 0 + data = resp.json() + # @odata.count — полный итог по фильтру, не зависит от $top + count = data.get("@odata.count") + if count is not None: + return int(count) + # Fallback: если count не вернулся — считаем по размеру value + return len(data.get("value") or []) + except Exception: + return 0 + + +async def get_client_last_comment(client_id: int) -> dict | None: + """ + Найти последний комментарий по любой незакрытой заявке клиента. + + Берём задачу с самым свежим updatedat → ищем последний комментарий в её истории. + Возвращает {text, author, date, task_number} или None. + """ + key = config.INTRADESK_TASKS_API_KEY + if not key: + return None + + # Шаг 1: самая свежая задача клиента + 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&$top=1" + ) + try: + async with httpx.AsyncClient() as client: + resp = await client.get(url, timeout=15.0) + if resp.status_code != 200: + return None + data = resp.json() + except Exception: + return None + + tasks = data.get("value") or [] + if not tasks: + return None + + task = tasks[0] + task_id = task.get("id") + task_number = task.get("tasknumber") + if not task_id: + return None + + # Шаг 2: последний комментарий этой задачи + comment = await get_task_last_comment(task_id) + if comment: + comment["task_number"] = task_number + return comment + + async def get_client_open_tasks( client_id: int, page: int = 0, @@ -69,8 +157,10 @@ async def get_client_open_tasks( Получить незакрытые заявки клиента из Intradesk API. Возвращает {"tasks": [...], "total": int}. - Каждая задача содержит поля из OData + "last_comment" (dict или None). + Последний комментарий загружается параллельно через history API для каждой задачи. """ + import asyncio + key = config.INTRADESK_TASKS_API_KEY if not key: return {"tasks": [], "total": 0} @@ -108,20 +198,31 @@ async def get_client_open_tasks( if did: dicts_map[did] = dname - tasks = [] + # Базовые данные задач + base_tasks = [] for t in raw_tasks: status_id = t.get("status") - last_comment = _extract_last_comment_from_lifetime(t.get("lifetime")) - tasks.append({ + base_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, }) + # Параллельно загружаем последний комментарий для каждой задачи + task_ids = [t["id"] for t in base_tasks if t["id"]] + comments = await asyncio.gather( + *[get_task_last_comment(tid) for tid in task_ids], + return_exceptions=True, + ) + + tasks = [] + for task, comment in zip(base_tasks, comments): + task["last_comment"] = comment if isinstance(comment, dict) else None + tasks.append(task) + return {"tasks": tasks, "total": total} @@ -133,15 +234,68 @@ def _extract_last_comment_from_lifetime(lifetime: dict | None) -> dict | None: 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" + short_date = raw_date[:16].replace("T", " ") # "2026-03-26 11:54" return { - "text": ev.get("stringvalue") or "", + "text": _strip_html(ev.get("stringvalue") or ""), "author": entry.get("username") or "—", "date": short_date, } return None +async def add_task_comment( + task_number: int, + text: str, + contact_person_id: int | None = None, +) -> bool: + """ + Добавить комментарий к заявке через Intradesk API (PUT /changes/v3/tasks). + + Возвращает True при успехе, False при ошибке. + """ + key = config.INTRADESK_CHANGES_API_KEY + if not key: + logger.warning("add_task_comment: INTRADESK_CHANGES_API_KEY не задан") + return False + + blocks: dict[str, str] = { + "comment": json.dumps({"value": text}, 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] add_task_comment — запрос НЕ отправлен\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( + "add_task_comment → HTTP %s: %s", + resp.status_code, + resp.text[:500], + ) + return False + logger.info("add_task_comment ok: task_number=%s", task_number) + return True + except Exception as exc: + logger.exception("add_task_comment error: %s", exc) + return False + + async def create_intradesk_task( name: str, description: str,