intra_max_chatbot/bot/handlers/view_tickets.py
2026-03-27 16:25:40 +03:00

493 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Обработчики просмотра заявок (Intradesk API).
Заявки загружаются из Intradesk API по нажатию кнопки — только незакрытые.
Навигация через callback payload:
"view_tickets" → список, страница 0
"itickets_page:{n}" → список, страница n
"iticket_detail:{id}" → карточка задачи (из кэша в context)
"main_menu" → в menu.py
"""
from __future__ import annotations
import re
import time
from maxapi.context import BaseContext
from maxapi.dispatcher import Router
from maxapi.filters import F
from maxapi.types.updates.message_callback import MessageCallback
import config
from bot.filters import PayloadStartsWith
from bot.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
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,
)
router = Router(router_id="view_tickets")
_PAGE_SIZE = 5
def _status_icon(status_name: str) -> str:
sn = status_name.lower()
if "выполнен" in sn or "закрыт" in sn:
return "🟢"
if "работ" in sn:
return "🟡"
if "открыт" in sn:
return "🔴"
return ""
def _fmt_date(iso: str) -> str:
"""'2026-03-26T09:57:11Z''26.03.2026 09:57'"""
if not iso or len(iso) < 10:
return iso or ""
y, m, d = iso[:10].split("-")
# iso может быть "2026-03-26T09:57:11Z" или "2026-03-26 09:57"
time_part = iso[11:16] if len(iso) >= 16 else ""
return f"{d}.{m}.{y}" + (f" {time_part}" if time_part else "")
def _fmt_date_short(iso: str) -> str:
"""'2026-03-26 11:54''26.03 11:54'"""
if not iso or len(iso) < 10:
return iso or ""
y, m, d = iso[:10].split("-")
time_part = iso[11:16] if len(iso) >= 16 else ""
return f"{d}.{m}" + (f" {time_part}" if time_part else "")
def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str:
pages = max(1, -(-total // _PAGE_SIZE))
lines = [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})')
else:
lines.append("💬 Нет комментариев")
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="")
await _load_and_show(event, bot_user, page=0, context=context)
@router.message_callback(PayloadStartsWith("itickets_page:"))
async def cb_itickets_page(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
await event.answer(notification="")
try:
page = int(event.callback.payload.split(":")[1])
except (IndexError, ValueError):
page = 0
await _load_and_show(event, bot_user, page=page, context=context)
@router.message_callback(PayloadStartsWith("iticket_detail:"))
async def cb_iticket_detail(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
try:
task_id = int(event.callback.payload.split(":")[1])
except (IndexError, ValueError):
await event.answer(notification="Ошибка")
return
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)
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"Статус: {icon} {task.get('status_name') or ''}",
f"Создана: {_fmt_date(task.get('created_at') or '')}",
f"Обновлена: {_fmt_date(task.get('updated_at') or '')}",
]
if task.get("name"):
lines += ["", f"📄 {task['name']}"]
if task.get("description"):
desc = task["description"]
if len(desc) > 600:
desc = desc[:600] + ""
lines += ["", "📝 Описание:", 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"], 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:"))
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": [],
# Сохраняем кэш списка чтобы кнопка «Назад» нашла задачу
"tasks": data.get("tasks"),
"page": data.get("page"),
"total": data.get("total"),
})
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
num_str = f" к заявке #{task_number}" if task_number else ""
mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=(
f"💬 Напишите комментарий{num_str}:\n\n"
"Отправьте текстовое сообщение или нажмите «← Назад к заявке» для отмены."
),
attachments=[comment_input_kb(task_id)],
)
# Сохраняем mid для возможной очистки
if mid:
d = await context.get_data()
d["flow_message_ids"] = [mid]
await context.set_data(d)
@router.message_created(states=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_id = data.get("comment_task_id")
task_number = data.get("comment_task_number")
# Сбрасываем состояние до отправки API — защита от повторов
await context.clear()
# Получаем данные организации (нужны и для contact_person_id, и для инвалидации кэша)
intradesk_id: int | None = None
contact_person_id: int | None = None
if bot_user.get("org_id"):
org = await get_organization(bot_user["org_id"])
intradesk_id = org.get("intradesk_id") if org else None
if intradesk_id and bot_user.get("phone"):
db = get_db()
contact_person_id = await get_intradesk_contact_id_for_phone(
db, intradesk_id, bot_user["phone"]
)
ok = await add_task_comment(
task_number=task_number,
text=text,
contact_person_id=contact_person_id,
)
if ok:
# Сбрасываем кэш — следующий переход в меню/список покажет свежие данные
if task_id:
invalidate_task_comment(task_id)
if intradesk_id:
invalidate_client_cache(intradesk_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()],
)