"""Тикеты поддержки — пользователь и админ в 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 = (
"🎫 Поддержка\n\n"
"Создай тикет — ответим здесь или в Mini App.\n"
f"Одновременно можно держать до {MAX_OPEN_PER_USER} открытых обращений."
)
def _admin_tickets_home_text(stats: dict) -> str:
return (
"🎫 Тикеты поддержки\n\n"
f"Открытых: {stats.get('open', 0)}\n"
f"С ответом: {stats.get('answered', 0)}\n"
f"Закрытых: {stats.get('closed', 0)}\n"
f"Всего: {stats.get('total', 0)}"
)
# ─── 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 = "🟢 Открытые тикеты" if only_open else "📋 Мои тикеты"
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"Новый тикет\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"Тема: {html.escape(subject)}\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"✅ Тикет #{tid} создан.{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"Ответ в тикет #{tid}\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"{title}\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"Ответ в тикет #{tid}\n\n"
f"Пользователь: {ticket['telegram_id']}\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",
)