"""Тикеты поддержки — пользователь и админ в 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", )