591 lines
20 KiB
Python
591 lines
20 KiB
Python
"""Тикеты поддержки — пользователь и админ в Telegram."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import html
|
||
|
||
from aiogram import Bot, F, Router
|
||
from aiogram.fsm.context import FSMContext
|
||
from aiogram.fsm.state import State, StatesGroup
|
||
from aiogram.types import CallbackQuery, Message
|
||
|
||
from config import Settings
|
||
from keyboards import (
|
||
admin_tickets_root_inline,
|
||
ticket_cancel_inline,
|
||
ticket_view_inline,
|
||
tickets_list_inline,
|
||
tickets_root_inline,
|
||
)
|
||
from services.db import Database
|
||
from services.ticket_ai import try_ai_ticket_reply
|
||
from services.tickets import (
|
||
BODY_MAX,
|
||
MAX_OPEN_PER_USER,
|
||
SUBJECT_MAX,
|
||
clamp_body,
|
||
clamp_subject,
|
||
format_admin_ticket_card,
|
||
format_ticket_card,
|
||
notify_admins_new_ticket,
|
||
notify_admins_ticket_reply,
|
||
notify_user_ticket_reply,
|
||
status_label,
|
||
)
|
||
|
||
router = Router(name="tickets")
|
||
|
||
|
||
class TicketCreateStates(StatesGroup):
|
||
waiting_subject = State()
|
||
waiting_body = State()
|
||
|
||
|
||
class TicketReplyStates(StatesGroup):
|
||
waiting_body = State()
|
||
|
||
|
||
class AdminTicketReplyStates(StatesGroup):
|
||
waiting_body = State()
|
||
|
||
|
||
TICKETS_INTRO = (
|
||
"<b>🎫 Поддержка</b>\n\n"
|
||
"Создай тикет — ответим здесь или в Mini App.\n"
|
||
f"Одновременно можно держать до <b>{MAX_OPEN_PER_USER}</b> открытых обращений."
|
||
)
|
||
|
||
|
||
def _admin_tickets_home_text(stats: dict) -> str:
|
||
return (
|
||
"<b>🎫 Тикеты поддержки</b>\n\n"
|
||
f"Открытых: <b>{stats.get('open', 0)}</b>\n"
|
||
f"С ответом: <b>{stats.get('answered', 0)}</b>\n"
|
||
f"Закрытых: <b>{stats.get('closed', 0)}</b>\n"
|
||
f"Всего: <b>{stats.get('total', 0)}</b>"
|
||
)
|
||
|
||
|
||
# ─── User ───────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@router.message(F.text == "🎫 Тикеты")
|
||
async def cmd_tickets(message: Message, state: FSMContext, db: Database) -> None:
|
||
await state.clear()
|
||
uid = message.from_user.id if message.from_user else 0
|
||
open_n = db.count_open_tickets(uid) if uid else 0
|
||
await message.answer(
|
||
TICKETS_INTRO,
|
||
reply_markup=tickets_root_inline(has_open=open_n > 0),
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data == "tix:root")
|
||
async def cb_tickets_root(callback: CallbackQuery, state: FSMContext, db: Database) -> None:
|
||
await state.clear()
|
||
await callback.answer()
|
||
uid = callback.from_user.id if callback.from_user else 0
|
||
open_n = db.count_open_tickets(uid) if uid else 0
|
||
if callback.message:
|
||
await callback.message.edit_text(
|
||
TICKETS_INTRO,
|
||
reply_markup=tickets_root_inline(has_open=open_n > 0),
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.in_({"tix:list", "tix:list:open"}))
|
||
async def cb_tickets_list(callback: CallbackQuery, db: Database) -> None:
|
||
await callback.answer()
|
||
uid = callback.from_user.id if callback.from_user else 0
|
||
only_open = (callback.data or "").endswith(":open")
|
||
tickets = db.list_tickets_for_user(
|
||
uid,
|
||
limit=20,
|
||
include_closed=not only_open,
|
||
)
|
||
if not tickets:
|
||
text = "Открытых тикетов нет." if only_open else "Тикетов пока нет."
|
||
if callback.message:
|
||
await callback.message.edit_text(
|
||
text,
|
||
reply_markup=tickets_root_inline(has_open=False),
|
||
parse_mode="HTML",
|
||
)
|
||
return
|
||
title = "<b>🟢 Открытые тикеты</b>" if only_open else "<b>📋 Мои тикеты</b>"
|
||
if callback.message:
|
||
await callback.message.edit_text(
|
||
title + "\n\nВыбери обращение:",
|
||
reply_markup=tickets_list_inline(tickets),
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("tix:view:"))
|
||
async def cb_ticket_view(callback: CallbackQuery, state: FSMContext, db: Database) -> None:
|
||
await state.clear()
|
||
try:
|
||
tid = int((callback.data or "").rsplit(":", 1)[-1])
|
||
except ValueError:
|
||
await callback.answer("Некорректный тикет", show_alert=True)
|
||
return
|
||
ticket = db.get_ticket(tid)
|
||
uid = callback.from_user.id if callback.from_user else 0
|
||
if not ticket or int(ticket["telegram_id"]) != uid:
|
||
await callback.answer("Тикет не найден", show_alert=True)
|
||
return
|
||
await callback.answer()
|
||
messages = db.list_ticket_messages(tid)
|
||
text = format_ticket_card(ticket, messages)
|
||
if callback.message:
|
||
await callback.message.edit_text(
|
||
text,
|
||
reply_markup=ticket_view_inline(tid, status=str(ticket["status"])),
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data == "tix:new")
|
||
async def cb_ticket_new(callback: CallbackQuery, state: FSMContext, db: Database) -> None:
|
||
uid = callback.from_user.id if callback.from_user else 0
|
||
if db.count_open_tickets(uid) >= MAX_OPEN_PER_USER:
|
||
await callback.answer(
|
||
f"Лимит: не больше {MAX_OPEN_PER_USER} открытых тикетов",
|
||
show_alert=True,
|
||
)
|
||
return
|
||
await callback.answer()
|
||
await state.set_state(TicketCreateStates.waiting_subject)
|
||
if callback.message:
|
||
await callback.message.edit_text(
|
||
f"<b>Новый тикет</b>\n\nНапиши тему (до {SUBJECT_MAX} символов):",
|
||
reply_markup=ticket_cancel_inline(),
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
|
||
@router.message(TicketCreateStates.waiting_subject)
|
||
async def ticket_subject_msg(message: Message, state: FSMContext) -> None:
|
||
subject = clamp_subject(message.text or "")
|
||
if len(subject) < 3:
|
||
await message.answer("Тема слишком короткая. Минимум 3 символа.")
|
||
return
|
||
await state.update_data(subject=subject)
|
||
await state.set_state(TicketCreateStates.waiting_body)
|
||
await message.answer(
|
||
f"Тема: <b>{html.escape(subject)}</b>\n\n"
|
||
f"Опиши проблему (до {BODY_MAX} символов):",
|
||
reply_markup=ticket_cancel_inline(),
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
|
||
@router.message(TicketCreateStates.waiting_body)
|
||
async def ticket_body_msg(
|
||
message: Message,
|
||
state: FSMContext,
|
||
db: Database,
|
||
settings: Settings,
|
||
bot: Bot,
|
||
) -> None:
|
||
body = clamp_body(message.text or "")
|
||
if len(body) < 5:
|
||
await message.answer("Сообщение слишком короткое.")
|
||
return
|
||
data = await state.get_data()
|
||
subject = clamp_subject(str(data.get("subject") or ""))
|
||
await state.clear()
|
||
uid = message.from_user.id if message.from_user else 0
|
||
if not uid or not subject:
|
||
await message.answer("Не удалось создать тикет. Попробуй снова.")
|
||
return
|
||
if db.count_open_tickets(uid) >= MAX_OPEN_PER_USER:
|
||
await message.answer(f"Лимит открытых тикетов: {MAX_OPEN_PER_USER}.")
|
||
return
|
||
db.ensure_user(
|
||
uid,
|
||
username=message.from_user.username if message.from_user else None,
|
||
full_name=message.from_user.full_name if message.from_user else None,
|
||
)
|
||
ticket = db.create_ticket(uid, subject=subject, body=body)
|
||
tid = int(ticket["id"])
|
||
ai_result = await try_ai_ticket_reply(
|
||
db=db,
|
||
bot=bot,
|
||
settings=settings,
|
||
ticket_id=tid,
|
||
notify_user=False,
|
||
)
|
||
ai_answered = ai_result.get("action") == "answer" and ai_result.get("ok")
|
||
# Если ИИ уже ответил пользователю — админам короткое уведомление.
|
||
# Если эскалация — ticket_ai уже пинганул. Если skip — обычное уведомление.
|
||
if ai_result.get("action") in ("skip", "error") or ai_answered:
|
||
if ai_result.get("action") != "escalate":
|
||
await notify_admins_new_ticket(
|
||
bot,
|
||
settings,
|
||
ticket_id=tid,
|
||
telegram_id=uid,
|
||
subject=subject,
|
||
body=body,
|
||
ai_handled=bool(ai_answered),
|
||
)
|
||
ticket = db.get_ticket(tid) or ticket
|
||
status = str(ticket.get("status") or "open")
|
||
extra = ""
|
||
if ai_answered:
|
||
extra = "\n🤖 ИИ уже ответил — смотри переписку ниже."
|
||
await message.answer(
|
||
f"✅ Тикет <b>#{tid}</b> создан.{extra}\n"
|
||
"Мы ответим в этом чате или в Mini App.",
|
||
reply_markup=ticket_view_inline(tid, status=status),
|
||
parse_mode="HTML",
|
||
)
|
||
if ai_answered:
|
||
messages = db.list_ticket_messages(tid)
|
||
await message.answer(
|
||
format_ticket_card(ticket, messages),
|
||
reply_markup=ticket_view_inline(tid, status=status),
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("tix:reply:"))
|
||
async def cb_ticket_reply(callback: CallbackQuery, state: FSMContext, db: Database) -> None:
|
||
try:
|
||
tid = int((callback.data or "").rsplit(":", 1)[-1])
|
||
except ValueError:
|
||
await callback.answer("Ошибка", show_alert=True)
|
||
return
|
||
ticket = db.get_ticket(tid)
|
||
uid = callback.from_user.id if callback.from_user else 0
|
||
if not ticket or int(ticket["telegram_id"]) != uid:
|
||
await callback.answer("Тикет не найден", show_alert=True)
|
||
return
|
||
if str(ticket["status"]) == "closed":
|
||
await callback.answer("Тикет закрыт", show_alert=True)
|
||
return
|
||
await callback.answer()
|
||
await state.set_state(TicketReplyStates.waiting_body)
|
||
await state.update_data(ticket_id=tid)
|
||
if callback.message:
|
||
await callback.message.edit_text(
|
||
f"<b>Ответ в тикет #{tid}</b>\n\nНапиши сообщение:",
|
||
reply_markup=ticket_cancel_inline(back=f"tix:view:{tid}"),
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
|
||
@router.message(TicketReplyStates.waiting_body)
|
||
async def ticket_reply_msg(
|
||
message: Message,
|
||
state: FSMContext,
|
||
db: Database,
|
||
settings: Settings,
|
||
bot: Bot,
|
||
) -> None:
|
||
body = clamp_body(message.text or "")
|
||
if len(body) < 1:
|
||
await message.answer("Пустое сообщение.")
|
||
return
|
||
data = await state.get_data()
|
||
tid = int(data.get("ticket_id") or 0)
|
||
await state.clear()
|
||
uid = message.from_user.id if message.from_user else 0
|
||
ticket = db.get_ticket(tid)
|
||
if not ticket or int(ticket["telegram_id"]) != uid:
|
||
await message.answer("Тикет не найден.")
|
||
return
|
||
try:
|
||
ticket = db.add_ticket_message(
|
||
tid,
|
||
author_role="user",
|
||
author_id=uid,
|
||
body=body,
|
||
)
|
||
except ValueError as exc:
|
||
await message.answer(str(exc))
|
||
return
|
||
ai_result = await try_ai_ticket_reply(
|
||
db=db,
|
||
bot=bot,
|
||
settings=settings,
|
||
ticket_id=tid,
|
||
notify_user=False,
|
||
)
|
||
if ai_result.get("action") in ("skip", "error"):
|
||
await notify_admins_ticket_reply(
|
||
bot,
|
||
settings,
|
||
ticket_id=tid,
|
||
telegram_id=uid,
|
||
body=body,
|
||
)
|
||
ticket = db.get_ticket(tid) or ticket
|
||
messages = db.list_ticket_messages(tid)
|
||
await message.answer(
|
||
format_ticket_card(ticket, messages),
|
||
reply_markup=ticket_view_inline(tid, status=str(ticket["status"])),
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("tix:close:"))
|
||
async def cb_ticket_close(callback: CallbackQuery, db: Database) -> None:
|
||
try:
|
||
tid = int((callback.data or "").rsplit(":", 1)[-1])
|
||
except ValueError:
|
||
await callback.answer("Ошибка", show_alert=True)
|
||
return
|
||
ticket = db.get_ticket(tid)
|
||
uid = callback.from_user.id if callback.from_user else 0
|
||
if not ticket or int(ticket["telegram_id"]) != uid:
|
||
await callback.answer("Тикет не найден", show_alert=True)
|
||
return
|
||
if str(ticket["status"]) == "closed":
|
||
await callback.answer("Уже закрыт")
|
||
return
|
||
db.set_ticket_status(tid, "closed")
|
||
await callback.answer("Закрыто")
|
||
ticket = db.get_ticket(tid)
|
||
messages = db.list_ticket_messages(tid)
|
||
if callback.message and ticket:
|
||
await callback.message.edit_text(
|
||
format_ticket_card(ticket, messages),
|
||
reply_markup=ticket_view_inline(tid, status="closed"),
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
|
||
# ─── Admin (same router; callbacks prefixed admin:tix / admin:tickets) ───────
|
||
|
||
|
||
@router.callback_query(F.data == "admin:tickets")
|
||
@router.callback_query(F.data.startswith("admin:tickets:"))
|
||
async def cb_admin_tickets(callback: CallbackQuery, state: FSMContext, db: Database, settings: Settings) -> None:
|
||
if not callback.from_user or callback.from_user.id not in settings.admin_ids:
|
||
await callback.answer("Нет доступа", show_alert=True)
|
||
return
|
||
await state.clear()
|
||
await callback.answer()
|
||
data = callback.data or "admin:tickets"
|
||
if data == "admin:tickets":
|
||
stats = db.ticket_stats()
|
||
if callback.message:
|
||
await callback.message.edit_text(
|
||
_admin_tickets_home_text(stats),
|
||
reply_markup=admin_tickets_root_inline(stats),
|
||
parse_mode="HTML",
|
||
)
|
||
return
|
||
|
||
status = data.rsplit(":", 1)[-1]
|
||
if status not in ("open", "answered", "closed", "all"):
|
||
status = "open"
|
||
tickets, _total = db.list_tickets_admin(status=status, limit=20)
|
||
if not tickets:
|
||
if callback.message:
|
||
await callback.message.edit_text(
|
||
"Тикетов в этом фильтре нет.",
|
||
reply_markup=admin_tickets_root_inline(db.ticket_stats()),
|
||
parse_mode="HTML",
|
||
)
|
||
return
|
||
title = {
|
||
"open": "🟢 Открытые",
|
||
"answered": "💬 С ответом",
|
||
"closed": "🔒 Закрытые",
|
||
"all": "📋 Все тикеты",
|
||
}.get(status, "Тикеты")
|
||
if callback.message:
|
||
await callback.message.edit_text(
|
||
f"<b>{title}</b>\n\nВыбери тикет:",
|
||
reply_markup=tickets_list_inline(
|
||
tickets,
|
||
prefix="admin:tix:view",
|
||
back="admin:tickets",
|
||
),
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("admin:tix:view:"))
|
||
async def cb_admin_ticket_view(
|
||
callback: CallbackQuery,
|
||
state: FSMContext,
|
||
db: Database,
|
||
settings: Settings,
|
||
) -> None:
|
||
if not callback.from_user or callback.from_user.id not in settings.admin_ids:
|
||
await callback.answer("Нет доступа", show_alert=True)
|
||
return
|
||
await state.clear()
|
||
try:
|
||
tid = int((callback.data or "").rsplit(":", 1)[-1])
|
||
except ValueError:
|
||
await callback.answer("Ошибка", show_alert=True)
|
||
return
|
||
ticket = db.get_ticket(tid)
|
||
if not ticket:
|
||
await callback.answer("Не найден", show_alert=True)
|
||
return
|
||
await callback.answer()
|
||
messages = db.list_ticket_messages(tid)
|
||
if callback.message:
|
||
await callback.message.edit_text(
|
||
format_admin_ticket_card(ticket, messages),
|
||
reply_markup=ticket_view_inline(
|
||
tid,
|
||
status=str(ticket["status"]),
|
||
is_admin=True,
|
||
),
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("admin:tix:reply:"))
|
||
async def cb_admin_ticket_reply(
|
||
callback: CallbackQuery,
|
||
state: FSMContext,
|
||
db: Database,
|
||
settings: Settings,
|
||
) -> None:
|
||
if not callback.from_user or callback.from_user.id not in settings.admin_ids:
|
||
await callback.answer("Нет доступа", show_alert=True)
|
||
return
|
||
try:
|
||
tid = int((callback.data or "").rsplit(":", 1)[-1])
|
||
except ValueError:
|
||
await callback.answer("Ошибка", show_alert=True)
|
||
return
|
||
ticket = db.get_ticket(tid)
|
||
if not ticket:
|
||
await callback.answer("Не найден", show_alert=True)
|
||
return
|
||
if str(ticket["status"]) == "closed":
|
||
await callback.answer("Тикет закрыт", show_alert=True)
|
||
return
|
||
await callback.answer()
|
||
await state.set_state(AdminTicketReplyStates.waiting_body)
|
||
await state.update_data(ticket_id=tid)
|
||
if callback.message:
|
||
await callback.message.edit_text(
|
||
f"<b>Ответ в тикет #{tid}</b>\n\n"
|
||
f"Пользователь: <code>{ticket['telegram_id']}</code>\n"
|
||
f"Статус: {status_label(str(ticket['status']))}\n\n"
|
||
"Напиши ответ:",
|
||
reply_markup=ticket_cancel_inline(back=f"admin:tix:view:{tid}"),
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
|
||
@router.message(AdminTicketReplyStates.waiting_body)
|
||
async def admin_ticket_reply_msg(
|
||
message: Message,
|
||
state: FSMContext,
|
||
db: Database,
|
||
settings: Settings,
|
||
bot: Bot,
|
||
) -> None:
|
||
if not message.from_user or message.from_user.id not in settings.admin_ids:
|
||
await state.clear()
|
||
return
|
||
body = clamp_body(message.text or "")
|
||
if len(body) < 1:
|
||
await message.answer("Пустое сообщение.")
|
||
return
|
||
data = await state.get_data()
|
||
tid = int(data.get("ticket_id") or 0)
|
||
await state.clear()
|
||
ticket = db.get_ticket(tid)
|
||
if not ticket:
|
||
await message.answer("Тикет не найден.")
|
||
return
|
||
try:
|
||
ticket = db.add_ticket_message(
|
||
tid,
|
||
author_role="admin",
|
||
author_id=message.from_user.id,
|
||
body=body,
|
||
)
|
||
except ValueError as exc:
|
||
await message.answer(str(exc))
|
||
return
|
||
await notify_user_ticket_reply(
|
||
bot,
|
||
telegram_id=int(ticket["telegram_id"]),
|
||
ticket_id=tid,
|
||
subject=str(ticket.get("subject") or ""),
|
||
body=body,
|
||
)
|
||
messages = db.list_ticket_messages(tid)
|
||
await message.answer(
|
||
format_admin_ticket_card(ticket, messages),
|
||
reply_markup=ticket_view_inline(
|
||
tid,
|
||
status=str(ticket["status"]),
|
||
is_admin=True,
|
||
),
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("admin:tix:close:"))
|
||
async def cb_admin_ticket_close(
|
||
callback: CallbackQuery,
|
||
db: Database,
|
||
settings: Settings,
|
||
) -> None:
|
||
if not callback.from_user or callback.from_user.id not in settings.admin_ids:
|
||
await callback.answer("Нет доступа", show_alert=True)
|
||
return
|
||
try:
|
||
tid = int((callback.data or "").rsplit(":", 1)[-1])
|
||
except ValueError:
|
||
await callback.answer("Ошибка", show_alert=True)
|
||
return
|
||
ticket = db.get_ticket(tid)
|
||
if not ticket:
|
||
await callback.answer("Не найден", show_alert=True)
|
||
return
|
||
db.set_ticket_status(tid, "closed")
|
||
await callback.answer("Закрыто")
|
||
ticket = db.get_ticket(tid)
|
||
messages = db.list_ticket_messages(tid)
|
||
if callback.message and ticket:
|
||
await callback.message.edit_text(
|
||
format_admin_ticket_card(ticket, messages),
|
||
reply_markup=ticket_view_inline(tid, status="closed", is_admin=True),
|
||
parse_mode="HTML",
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("admin:tix:reopen:"))
|
||
async def cb_admin_ticket_reopen(
|
||
callback: CallbackQuery,
|
||
db: Database,
|
||
settings: Settings,
|
||
) -> None:
|
||
if not callback.from_user or callback.from_user.id not in settings.admin_ids:
|
||
await callback.answer("Нет доступа", show_alert=True)
|
||
return
|
||
try:
|
||
tid = int((callback.data or "").rsplit(":", 1)[-1])
|
||
except ValueError:
|
||
await callback.answer("Ошибка", show_alert=True)
|
||
return
|
||
ticket = db.set_ticket_status(tid, "open")
|
||
if not ticket:
|
||
await callback.answer("Не найден", show_alert=True)
|
||
return
|
||
await callback.answer("Открыт снова")
|
||
messages = db.list_ticket_messages(tid)
|
||
if callback.message:
|
||
await callback.message.edit_text(
|
||
format_admin_ticket_card(ticket, messages),
|
||
reply_markup=ticket_view_inline(tid, status="open", is_admin=True),
|
||
parse_mode="HTML",
|
||
)
|