intra_max_chatbot/bot/handlers/view_tickets.py
2026-04-03 00:30:24 +03:00

544 lines
20 KiB
Python

"""
Обработчики просмотра заявок (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.flow_config import PAYLOADS, TRANSITIONS
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.texts import CommentTexts, TicketDetailTexts, TicketListTexts
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 = [TicketListTexts.HEADER.format(total=total, page=page + 1, pages=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(TicketListTexts.NO_COMMENTS)
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=TicketListTexts.LOADING,
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=TicketListTexts.NO_ORG,
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=TicketListTexts.EMPTY,
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 == PAYLOADS.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=TicketListTexts.LOADING,
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(PAYLOADS.PFX_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=TicketListTexts.LOADING,
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(PAYLOADS.PFX_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=TicketDetailTexts.GENERIC_ERROR)
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 == TRANSITIONS["comment:start"]:
d = await context.get_data()
for mid in (d.get("flow_message_ids") or []):
await _delete_msg(event.bot, mid)
await context.set_state(TRANSITIONS["comment:done"])
# Сохраняем кэш задач, но сбрасываем 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=TicketDetailTexts.LOADING,
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=TicketDetailTexts.NOT_FOUND,
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 += ["", TicketDetailTexts.DESCRIPTION_HEADER, desc]
# Последние 10 комментариев из history API
comments = await get_task_comments(task["id"], count=10)
if comments:
lines += ["", TicketDetailTexts.COMMENTS_HEADER.format(count=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 += ["", TicketDetailTexts.NO_COMMENTS]
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))],
)
from database.queries.reply_context import set_reply_context
await set_reply_context(bot_user["max_user_id"], task_id, task.get("task_number"))
# ── Закрыть заявку ────────────────────────────────────────────────────────────
@router.message_callback(PayloadStartsWith(PAYLOADS.PFX_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=TicketDetailTexts.GENERIC_ERROR)
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=TicketDetailTexts.CLOSING,
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=TicketDetailTexts.CLOSE_NUMBER_ERROR,
attachments=[iticket_detail_kb()],
)
return
nav_mid = await nav_prepare(
event, context,
loading_text=TicketDetailTexts.CLOSING,
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=TicketDetailTexts.CLOSED.format(num_str=num_str),
attachments=[menu_only_kb()],
)
else:
await nav_finish(
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
text=TicketDetailTexts.CLOSE_ERROR,
attachments=[iticket_detail_kb(task_id=task_id, can_close=bool(config.INTRADESK_CLOSE_STATUS_ID))],
)
# ── Оставить комментарий ──────────────────────────────────────────────────────
@router.message_callback(PayloadStartsWith(PAYLOADS.PFX_ITICKET_COMMENT))
async def cb_add_comment(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
# Защита от спама: если уже в режиме ввода комментария — игнорируем повторный клик
state = await context.get_state()
if state == TRANSITIONS["comment:start"]:
await event.answer(notification="")
return
try:
task_id = int(event.callback.payload.split(":")[1])
except (IndexError, ValueError):
await event.answer(notification=TicketDetailTexts.GENERIC_ERROR)
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(TRANSITIONS["comment:start"])
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=CommentTexts.PROMPT.format(num_str=num_str),
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=TRANSITIONS["comment:start"])
async def on_comment_text(
event, 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=CommentTexts.EMPTY_ERROR,
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 CommentTexts.FILE_ONLY
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)
num_str = f" к заявке #{task_number}" if task_number else ""
result_text = CommentTexts.SUCCESS.format(num_str=num_str) if ok else CommentTexts.ERROR
menu_mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=user_id,
text=result_text,
attachments=[main_menu_kb()],
)
await nav_set(context, menu_mid)