493 lines
18 KiB
Python
493 lines
18 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.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()],
|
||
)
|