intra_max_chatbot/bot/handlers/view_tickets.py

553 lines
20 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.handlers.create_ticket import _extract_attachments
from bot.keyboards import comment_input_kb, iticket_detail_kb, main_menu_kb, menu_only_kb
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
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,
upload_attachments,
)
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 _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 и показать.
nav_mid — уже отображённый индикатор загрузки (из nav_prepare).
Если None — отправляет новое сообщение самостоятельно.
Редактирует nav_mid результатом; перед рендером проверяет 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())
chat_id = event.message.recipient.chat_id if event.message else None
# Если 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, nav_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:
await _delete_msg(event.bot, nav_mid)
return
# Сохраняем задачи в контексте для детального просмотра
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, nav_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, 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:
"""Редактировать сообщение по 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="")
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:"))
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
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:"))
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="")
chat_id = event.message.recipient.chat_id if event.message else None
# Если пришли сюда из комментарийного флоу (кнопка «Назад») —
# удаляем промпт-сообщение и сбрасываем 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 []):
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 nav_finish(
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
text="⚠️ Заявка не найдена. Вернитесь к списку.",
attachments=[iticket_detail_kb()],
)
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 += [
"",
"" * 16,
f"{c['date']}{c['author']}",
ctext,
]
else:
lines += ["", "💬 Комментариев нет"]
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))],
)
# ── Закрыть заявку ────────────────────────────────────────────────────────────
@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
chat_id = event.message.recipient.chat_id if event.message else None
if not task_number:
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()],
)
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
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 nav_finish(
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
text=f"✅ Заявка{num_str} закрыта.",
attachments=[menu_only_kb()],
)
else:
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))],
)
# ── Оставить комментарий ──────────────────────────────────────────────────────
@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"
"Можно отправить текст, файл, фото или голосовое сообщение.\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 ""
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="❗ Комментарий не может быть пустым. Напишите текст или прикрепите файл:",
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"]
)
# Загружаем файлы в 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=comment_text,
contact_person_id=contact_person_id,
attachments=file_atts or None,
)
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="⚠️ Не удалось отправить комментарий. Попробуйте позже.",
)
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)