eb18b3a0f9
Messages older than 48 hours cannot be deleted via Telegram API. Now falls back to editing the message text instead of crashing.
1162 lines
48 KiB
Python
1162 lines
48 KiB
Python
import asyncio
|
||
import html
|
||
import time
|
||
|
||
import structlog
|
||
from aiogram import Bot, Dispatcher, F, types
|
||
from aiogram.exceptions import TelegramBadRequest
|
||
from aiogram.fsm.context import FSMContext
|
||
from aiogram.fsm.state import State, StatesGroup
|
||
from aiogram.types import InaccessibleMessage
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
|
||
from app.database.crud.user import get_user_by_id
|
||
from app.database.models import Ticket, TicketStatus, User
|
||
from app.keyboards.inline import (
|
||
get_my_tickets_keyboard,
|
||
get_ticket_cancel_keyboard,
|
||
get_ticket_reply_cancel_keyboard,
|
||
get_ticket_view_keyboard,
|
||
)
|
||
from app.localization.texts import get_texts
|
||
from app.services.admin_notification_service import AdminNotificationService
|
||
from app.utils.cache import RateLimitCache, cache, cache_key
|
||
from app.utils.photo_message import edit_or_answer_photo
|
||
from app.utils.timezone import format_local_datetime
|
||
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
|
||
class TicketStates(StatesGroup):
|
||
waiting_for_title = State()
|
||
waiting_for_message = State()
|
||
waiting_for_reply = State()
|
||
|
||
|
||
async def show_ticket_priority_selection(
|
||
callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession
|
||
):
|
||
"""Начать создание тикета без выбора приоритета: сразу просим заголовок"""
|
||
texts = get_texts(db_user.language)
|
||
|
||
# Глобальный блок и наличие активного тикета
|
||
from app.database.crud.ticket import TicketCRUD
|
||
|
||
blocked_until = await TicketCRUD.is_user_globally_blocked(db, db_user.id)
|
||
if blocked_until:
|
||
if blocked_until.year > 9999 - 1:
|
||
await callback.answer(
|
||
texts.t('USER_BLOCKED_FOREVER', 'Вы заблокированы для обращений в поддержку.'), show_alert=True
|
||
)
|
||
else:
|
||
await callback.answer(
|
||
texts.t('USER_BLOCKED_UNTIL', 'Вы заблокированы до {time}').format(
|
||
time=blocked_until.strftime('%d.%m.%Y %H:%M')
|
||
),
|
||
show_alert=True,
|
||
)
|
||
return
|
||
if await TicketCRUD.user_has_active_ticket(db, db_user.id):
|
||
await callback.answer(
|
||
texts.t('TICKET_ALREADY_OPEN', 'У вас уже есть незакрытый тикет. Сначала закройте его.'), show_alert=True
|
||
)
|
||
return
|
||
|
||
prompt_text = texts.t('TICKET_TITLE_INPUT', 'Введите заголовок тикета:')
|
||
cancel_kb = get_ticket_cancel_keyboard(db_user.language)
|
||
prompt_msg = callback.message
|
||
try:
|
||
await callback.message.edit_text(prompt_text, reply_markup=cancel_kb)
|
||
except TelegramBadRequest:
|
||
# Предыдущее сообщение — фото (нет текста для edit_text), удаляем и шлём новое
|
||
try:
|
||
await callback.message.delete()
|
||
except Exception:
|
||
pass
|
||
prompt_msg = await callback.message.answer(prompt_text, reply_markup=cancel_kb)
|
||
# Запоминаем исходное сообщение бота, чтобы далее редактировать его, а не слать новые
|
||
await state.update_data(prompt_chat_id=prompt_msg.chat.id, prompt_message_id=prompt_msg.message_id)
|
||
await state.set_state(TicketStates.waiting_for_title)
|
||
await callback.answer()
|
||
|
||
|
||
async def handle_ticket_title_input(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession):
|
||
# Проверяем, что пользователь в правильном состоянии
|
||
current_state = await state.get_state()
|
||
if current_state != TicketStates.waiting_for_title:
|
||
return
|
||
|
||
"""Обработать ввод заголовка тикета"""
|
||
if not message.text:
|
||
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
|
||
return
|
||
title = message.text.strip()
|
||
|
||
data_prompt = await state.get_data()
|
||
prompt_chat_id = data_prompt.get('prompt_chat_id')
|
||
prompt_message_id = data_prompt.get('prompt_message_id')
|
||
# Удалим сообщение пользователя через 2 секунды, чтобы не засорять чат
|
||
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
|
||
if len(title) < 5:
|
||
texts = get_texts(db_user.language)
|
||
text_val = texts.t(
|
||
'TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:'
|
||
)
|
||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
|
||
return
|
||
|
||
if len(title) > 255:
|
||
texts = get_texts(db_user.language)
|
||
text_val = texts.t(
|
||
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
|
||
)
|
||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
|
||
return
|
||
|
||
# Глобальный блок
|
||
from app.database.crud.ticket import TicketCRUD
|
||
|
||
blocked_until = await TicketCRUD.is_user_globally_blocked(db, db_user.id)
|
||
if blocked_until:
|
||
texts = get_texts(db_user.language)
|
||
if blocked_until.year > 9999 - 1:
|
||
await message.answer(texts.t('USER_BLOCKED_FOREVER', 'Вы заблокированы для обращений в поддержку.'))
|
||
else:
|
||
await message.answer(
|
||
texts.t('USER_BLOCKED_UNTIL', 'Вы заблокированы до {time}').format(
|
||
time=blocked_until.strftime('%d.%m.%Y %H:%M')
|
||
)
|
||
)
|
||
await state.clear()
|
||
return
|
||
|
||
await state.update_data(title=title)
|
||
|
||
texts = get_texts(db_user.language)
|
||
text_val = texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:')
|
||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
|
||
|
||
await state.set_state(TicketStates.waiting_for_message)
|
||
|
||
|
||
async def handle_ticket_message_input(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession):
|
||
# Проверяем, что пользователь в правильном состоянии
|
||
current_state = await state.get_state()
|
||
if current_state != TicketStates.waiting_for_message:
|
||
return
|
||
|
||
# Защита от спама: принимаем только первое сообщение в коротком окне
|
||
try:
|
||
# Глобальный мягкий супрессор на 6 секунд после создания тикета
|
||
try:
|
||
from_cache = await cache.get(cache_key('suppress_user_input', db_user.id))
|
||
if from_cache:
|
||
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
|
||
return
|
||
except Exception:
|
||
pass
|
||
limited = await RateLimitCache.is_rate_limited(db_user.id, 'ticket_create_message', limit=1, window=2)
|
||
if limited:
|
||
# Удаляем лишние части длинного сообщения
|
||
try:
|
||
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
|
||
except Exception:
|
||
pass
|
||
return
|
||
except Exception:
|
||
pass
|
||
try:
|
||
data_rl = await state.get_data()
|
||
last_ts = data_rl.get('rl_ts_create')
|
||
now_ts = time.time()
|
||
if last_ts and (now_ts - float(last_ts)) < 2:
|
||
try:
|
||
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
|
||
except Exception:
|
||
pass
|
||
return
|
||
await state.update_data(rl_ts_create=now_ts)
|
||
except Exception:
|
||
pass
|
||
|
||
"""Обработать ввод сообщения тикета и создать тикет"""
|
||
# Поддержка фото: если прислали фото с подписью — берём caption, сохраняем file_id
|
||
message_text = (message.text or message.caption or '').strip()
|
||
# Ограничим длину текста описания тикета, чтобы избежать проблем с caption/рендером
|
||
if len(message_text) > 500:
|
||
message_text = message_text[:500]
|
||
media_type = None
|
||
media_file_id = None
|
||
media_caption = None
|
||
if message.photo:
|
||
media_type = 'photo'
|
||
media_file_id = message.photo[-1].file_id
|
||
media_caption = message.caption
|
||
# Глобальный блок
|
||
from app.database.crud.ticket import TicketCRUD
|
||
|
||
blocked_until = await TicketCRUD.is_user_globally_blocked(db, db_user.id)
|
||
if blocked_until:
|
||
texts = get_texts(db_user.language)
|
||
data_prompt = await state.get_data()
|
||
prompt_chat_id = data_prompt.get('prompt_chat_id')
|
||
prompt_message_id = data_prompt.get('prompt_message_id')
|
||
text_msg = (
|
||
texts.t('USER_BLOCKED_FOREVER', 'Вы заблокированы для обращений в поддержку.')
|
||
if blocked_until.year > 9999 - 1
|
||
else texts.t('USER_BLOCKED_UNTIL', 'Вы заблокированы до {time}').format(
|
||
time=blocked_until.strftime('%d.%m.%Y %H:%M')
|
||
)
|
||
)
|
||
if prompt_chat_id and prompt_message_id:
|
||
try:
|
||
await message.bot.edit_message_text(chat_id=prompt_chat_id, message_id=prompt_message_id, text=text_msg)
|
||
except TelegramBadRequest:
|
||
await message.answer(text_msg)
|
||
else:
|
||
await message.answer(text_msg)
|
||
await state.clear()
|
||
return
|
||
|
||
# Удалим сообщение пользователя через 2 секунды
|
||
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
|
||
# Валидируем: допускаем пустой текст, если есть фото
|
||
if (not message_text or len(message_text) < 10) and not message.photo:
|
||
texts = get_texts(db_user.language)
|
||
data_prompt = await state.get_data()
|
||
prompt_chat_id = data_prompt.get('prompt_chat_id')
|
||
prompt_message_id = data_prompt.get('prompt_message_id')
|
||
err_text = texts.t(
|
||
'TICKET_MESSAGE_TOO_SHORT', 'Сообщение слишком короткое. Опишите проблему подробнее или отправьте фото:'
|
||
)
|
||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, err_text, db_user.language)
|
||
return
|
||
|
||
data = await state.get_data()
|
||
title = data.get('title')
|
||
priority = 'normal'
|
||
|
||
try:
|
||
ticket = await TicketCRUD.create_ticket(
|
||
db,
|
||
db_user.id,
|
||
title,
|
||
message_text,
|
||
priority,
|
||
media_type=media_type,
|
||
media_file_id=media_file_id,
|
||
media_caption=media_caption,
|
||
)
|
||
# Включим временное подавление лишних сообщений пользователя (на случай разбиения длинного текста)
|
||
try:
|
||
await cache.set(cache_key('suppress_user_input', db_user.id), True, 6)
|
||
except Exception:
|
||
pass
|
||
|
||
texts = get_texts(db_user.language)
|
||
# Ограничим длину подтверждения чтобы не упереться в лимиты
|
||
safe_title = html.escape(title if len(title) <= 200 else (title[:197] + '...'))
|
||
creation_text = (
|
||
f'✅ <b>Тикет #{ticket.id} создан</b>\n\n'
|
||
f'📝 Заголовок: {safe_title}\n'
|
||
f'📊 Статус: {ticket.status_emoji} '
|
||
f'{texts.t("TICKET_STATUS_OPEN", "Открыт")}\n'
|
||
f'📅 Создан: {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n'
|
||
+ ('📎 Вложение: фото\n' if media_type == 'photo' else '')
|
||
)
|
||
|
||
data_prompt = await state.get_data()
|
||
prompt_chat_id = data_prompt.get('prompt_chat_id')
|
||
prompt_message_id = data_prompt.get('prompt_message_id')
|
||
keyboard = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('VIEW_TICKET', '👁️ Посмотреть тикет'), callback_data=f'view_ticket_{ticket.id}'
|
||
)
|
||
],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('BACK_TO_MENU', '🏠 В главное меню'), callback_data='back_to_menu'
|
||
)
|
||
],
|
||
]
|
||
)
|
||
if prompt_chat_id and prompt_message_id:
|
||
try:
|
||
await message.bot.edit_message_text(
|
||
chat_id=prompt_chat_id,
|
||
message_id=prompt_message_id,
|
||
text=creation_text,
|
||
reply_markup=keyboard,
|
||
parse_mode='HTML',
|
||
)
|
||
except TelegramBadRequest:
|
||
await message.answer(creation_text, reply_markup=keyboard, parse_mode='HTML')
|
||
else:
|
||
await message.answer(creation_text, reply_markup=keyboard, parse_mode='HTML')
|
||
|
||
await state.clear()
|
||
|
||
# Уведомить админов
|
||
await notify_admins_about_new_ticket(ticket, db)
|
||
|
||
except Exception as e:
|
||
logger.error('Error creating ticket', error=e)
|
||
texts = get_texts(db_user.language)
|
||
await message.answer(
|
||
texts.t('TICKET_CREATE_ERROR', '❌ Произошла ошибка при создании тикета. Попробуйте позже.')
|
||
)
|
||
|
||
|
||
async def show_my_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
texts = get_texts(db_user.language)
|
||
|
||
# Определяем текущую страницу
|
||
current_page = 1
|
||
if callback.data.startswith('my_tickets_page_'):
|
||
try:
|
||
current_page = int(callback.data.replace('my_tickets_page_', ''))
|
||
except ValueError:
|
||
current_page = 1
|
||
|
||
# Пагинация открытых тикетов из БД
|
||
per_page = 10
|
||
total_open = await TicketCRUD.count_user_tickets_by_statuses(
|
||
db, db_user.id, [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value, TicketStatus.PENDING.value]
|
||
)
|
||
total_pages = max(1, (total_open + per_page - 1) // per_page)
|
||
current_page = max(1, min(current_page, total_pages))
|
||
offset = (current_page - 1) * per_page
|
||
open_tickets = await TicketCRUD.get_user_tickets_by_statuses(
|
||
db,
|
||
db_user.id,
|
||
[TicketStatus.OPEN.value, TicketStatus.ANSWERED.value, TicketStatus.PENDING.value],
|
||
limit=per_page,
|
||
offset=offset,
|
||
)
|
||
|
||
# Проверка на отсутствие тикетов совсем (ни открытых, ни закрытых)
|
||
has_closed_any = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value]) > 0
|
||
if not open_tickets and not has_closed_any:
|
||
await callback.message.edit_text(
|
||
texts.t('NO_TICKETS', 'У вас пока нет тикетов.'),
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('CREATE_TICKET_BUTTON', '🎫 Создать тикет'), callback_data='create_ticket'
|
||
)
|
||
],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('VIEW_CLOSED_TICKETS', '🟢 Закрытые тикеты'), callback_data='my_tickets_closed'
|
||
)
|
||
],
|
||
[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_support')],
|
||
]
|
||
),
|
||
)
|
||
await callback.answer()
|
||
return
|
||
|
||
# Открытые с пагинацией (DB)
|
||
open_data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in open_tickets]
|
||
keyboard = get_my_tickets_keyboard(
|
||
open_data,
|
||
current_page=current_page,
|
||
total_pages=total_pages,
|
||
language=db_user.language,
|
||
page_prefix='my_tickets_page_',
|
||
)
|
||
# Добавим кнопку перехода к закрытым
|
||
keyboard.inline_keyboard.insert(
|
||
0,
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('VIEW_CLOSED_TICKETS', '🟢 Закрытые тикеты'), callback_data='my_tickets_closed'
|
||
)
|
||
],
|
||
)
|
||
# Всегда используем фото-рендер с логотипом (утилита сама сделает фоллбек при необходимости)
|
||
await edit_or_answer_photo(
|
||
callback=callback,
|
||
caption=texts.t('MY_TICKETS_TITLE', '📋 Ваши тикеты:'),
|
||
keyboard=keyboard,
|
||
parse_mode='HTML',
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
async def show_my_tickets_closed(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
texts = get_texts(db_user.language)
|
||
# Пагинация закрытых
|
||
current_page = 1
|
||
data_str = callback.data
|
||
if data_str.startswith('my_tickets_closed_page_'):
|
||
try:
|
||
current_page = int(data_str.replace('my_tickets_closed_page_', ''))
|
||
except ValueError:
|
||
current_page = 1
|
||
|
||
per_page = 10
|
||
total_closed = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value])
|
||
if total_closed == 0:
|
||
await callback.message.edit_text(
|
||
texts.t('NO_CLOSED_TICKETS', 'Закрытых тикетов пока нет.'),
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('BACK_TO_OPEN_TICKETS', '🔴 Открытые тикеты'), callback_data='my_tickets'
|
||
)
|
||
],
|
||
[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_support')],
|
||
]
|
||
),
|
||
)
|
||
await callback.answer()
|
||
return
|
||
total_pages = max(1, (total_closed + per_page - 1) // per_page)
|
||
current_page = max(1, min(current_page, total_pages))
|
||
offset = (current_page - 1) * per_page
|
||
tickets = await TicketCRUD.get_user_tickets_by_statuses(
|
||
db, db_user.id, [TicketStatus.CLOSED.value], limit=per_page, offset=offset
|
||
)
|
||
data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in tickets]
|
||
kb = get_my_tickets_keyboard(
|
||
data,
|
||
current_page=current_page,
|
||
total_pages=total_pages,
|
||
language=db_user.language,
|
||
page_prefix='my_tickets_closed_page_',
|
||
)
|
||
kb.inline_keyboard.insert(
|
||
0,
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('BACK_TO_OPEN_TICKETS', '🔴 Открытые тикеты'), callback_data='my_tickets'
|
||
)
|
||
],
|
||
)
|
||
await edit_or_answer_photo(
|
||
callback=callback,
|
||
caption=texts.t('CLOSED_TICKETS_TITLE', '🟢 Закрытые тикеты:'),
|
||
keyboard=kb,
|
||
parse_mode='HTML',
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
def _split_long_block(block: str, max_len: int) -> list[str]:
|
||
"""Разбивает слишком длинный блок на части."""
|
||
if len(block) <= max_len:
|
||
return [block]
|
||
|
||
parts = []
|
||
remaining = block
|
||
while remaining:
|
||
if len(remaining) <= max_len:
|
||
parts.append(remaining)
|
||
break
|
||
# Ищем место для разрыва (перенос строки или пробел)
|
||
cut_at = max_len
|
||
newline_pos = remaining.rfind('\n', 0, max_len)
|
||
space_pos = remaining.rfind(' ', 0, max_len)
|
||
|
||
if newline_pos > max_len // 2:
|
||
cut_at = newline_pos + 1
|
||
elif space_pos > max_len // 2:
|
||
cut_at = space_pos + 1
|
||
|
||
parts.append(remaining[:cut_at])
|
||
remaining = remaining[cut_at:]
|
||
|
||
return parts
|
||
|
||
|
||
def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int = 3500) -> list[str]:
|
||
"""Разбивает текст на страницы с учётом лимита Telegram."""
|
||
pages: list[str] = []
|
||
current = header
|
||
header_len = len(header)
|
||
block_max_len = max_len - header_len - 50 # запас для безопасности
|
||
|
||
for block in message_blocks:
|
||
# Если блок сам по себе слишком длинный — разбиваем его
|
||
if len(block) > block_max_len:
|
||
block_parts = _split_long_block(block, block_max_len)
|
||
for part in block_parts:
|
||
if len(current) + len(part) > max_len:
|
||
if current.strip() and current != header:
|
||
pages.append(current)
|
||
current = header + part
|
||
else:
|
||
current += part
|
||
elif len(current) + len(block) > max_len:
|
||
if current.strip() and current != header:
|
||
pages.append(current)
|
||
current = header + block
|
||
else:
|
||
current += block
|
||
|
||
if current.strip():
|
||
pages.append(current)
|
||
|
||
return pages or [header]
|
||
|
||
|
||
async def view_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
"""Показать детали тикета с пагинацией"""
|
||
data_str = callback.data
|
||
page = 1
|
||
ticket_id = None
|
||
if data_str.startswith('ticket_view_page_'):
|
||
# format: ticket_view_page_{ticket_id}_{page}
|
||
try:
|
||
_, _, _, tid, p = data_str.split('_')
|
||
ticket_id = int(tid)
|
||
page = max(1, int(p))
|
||
except Exception:
|
||
pass
|
||
if ticket_id is None:
|
||
ticket_id = int(data_str.replace('view_ticket_', ''))
|
||
|
||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True)
|
||
|
||
if not ticket or ticket.user_id != db_user.id:
|
||
texts = get_texts(db_user.language)
|
||
await callback.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.'), show_alert=True)
|
||
return
|
||
|
||
texts = get_texts(db_user.language)
|
||
|
||
# Формируем текст тикета
|
||
status_text = {
|
||
TicketStatus.OPEN.value: texts.t('TICKET_STATUS_OPEN', 'Открыт'),
|
||
TicketStatus.ANSWERED.value: texts.t('TICKET_STATUS_ANSWERED', 'Отвечен'),
|
||
TicketStatus.CLOSED.value: texts.t('TICKET_STATUS_CLOSED', 'Закрыт'),
|
||
TicketStatus.PENDING.value: texts.t('TICKET_STATUS_PENDING', 'В ожидании'),
|
||
}.get(ticket.status, ticket.status)
|
||
|
||
header = (
|
||
f'🎫 Тикет #{ticket.id}\n\n'
|
||
f'📝 Заголовок: {html.escape(ticket.title or "")}\n'
|
||
f'📊 Статус: {ticket.status_emoji} {status_text}\n'
|
||
f'📅 Создан: {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n\n'
|
||
)
|
||
message_blocks: list[str] = []
|
||
if ticket.messages:
|
||
message_blocks.append(f'💬 Сообщения ({len(ticket.messages)}):\n\n')
|
||
for msg in ticket.messages:
|
||
sender = '👤 Вы' if msg.is_user_message else '🛠️ Поддержка'
|
||
block = f'{sender} ({format_local_datetime(msg.created_at, "%d.%m %H:%M")}):\n{html.escape(msg.message_text or "")}\n\n'
|
||
if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo':
|
||
block += '📎 Вложение: фото\n\n'
|
||
message_blocks.append(block)
|
||
pages = _split_text_into_pages(header, message_blocks, max_len=3500)
|
||
total_pages = len(pages)
|
||
page = min(page, total_pages)
|
||
|
||
keyboard = get_ticket_view_keyboard(
|
||
ticket_id,
|
||
ticket.is_closed,
|
||
db_user.language,
|
||
)
|
||
# Если есть вложения фото — добавим кнопку для просмотра
|
||
has_photos = any(
|
||
getattr(m, 'has_media', False) and getattr(m, 'media_type', None) == 'photo' for m in ticket.messages or []
|
||
)
|
||
if has_photos:
|
||
try:
|
||
keyboard.inline_keyboard.insert(
|
||
0,
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('TICKET_ATTACHMENTS', '📎 Вложения'),
|
||
callback_data=f'ticket_attachments_{ticket_id}',
|
||
)
|
||
],
|
||
)
|
||
except Exception:
|
||
pass
|
||
# Пагинация
|
||
if total_pages > 1:
|
||
nav_row = []
|
||
if page > 1:
|
||
nav_row.append(
|
||
types.InlineKeyboardButton(text='⬅️', callback_data=f'ticket_view_page_{ticket_id}_{page - 1}')
|
||
)
|
||
nav_row.append(types.InlineKeyboardButton(text=f'{page}/{total_pages}', callback_data='noop'))
|
||
if page < total_pages:
|
||
nav_row.append(
|
||
types.InlineKeyboardButton(text='➡️', callback_data=f'ticket_view_page_{ticket_id}_{page + 1}')
|
||
)
|
||
try:
|
||
keyboard.inline_keyboard.insert(0, nav_row)
|
||
except Exception:
|
||
pass
|
||
# Показываем как текст (чтобы не упереться в caption лимит)
|
||
page_text = pages[page - 1]
|
||
try:
|
||
await callback.message.edit_text(page_text, reply_markup=keyboard)
|
||
except Exception:
|
||
try:
|
||
await callback.message.delete()
|
||
except Exception:
|
||
pass
|
||
await callback.message.answer(page_text, reply_markup=keyboard)
|
||
await callback.answer()
|
||
|
||
|
||
async def send_ticket_attachments(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
texts = get_texts(db_user.language)
|
||
try:
|
||
await callback.answer(texts.t('SENDING_ATTACHMENTS', '📎 Отправляю вложения...'))
|
||
except Exception:
|
||
pass
|
||
try:
|
||
ticket_id = int(callback.data.replace('ticket_attachments_', ''))
|
||
except ValueError:
|
||
await callback.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.'), show_alert=True)
|
||
return
|
||
|
||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True)
|
||
if not ticket or ticket.user_id != db_user.id:
|
||
await callback.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.'), show_alert=True)
|
||
return
|
||
|
||
photos = [
|
||
m.media_file_id
|
||
for m in ticket.messages
|
||
if getattr(m, 'has_media', False) and getattr(m, 'media_type', None) == 'photo' and m.media_file_id
|
||
]
|
||
if not photos:
|
||
await callback.answer(texts.t('NO_ATTACHMENTS', 'Вложений нет.'), show_alert=True)
|
||
return
|
||
|
||
# Telegram ограничивает media group до 10 элементов. Отправим чанками.
|
||
from aiogram.types import InputMediaPhoto
|
||
|
||
chunks = [photos[i : i + 10] for i in range(0, len(photos), 10)]
|
||
last_group_message = None
|
||
for chunk in chunks:
|
||
media = [InputMediaPhoto(media=pid) for pid in chunk]
|
||
try:
|
||
messages = await callback.message.bot.send_media_group(chat_id=callback.from_user.id, media=media)
|
||
if messages:
|
||
last_group_message = messages[-1]
|
||
except Exception:
|
||
pass
|
||
if last_group_message:
|
||
try:
|
||
kb = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('DELETE_MESSAGE', '🗑 Удалить'),
|
||
callback_data=f'user_delete_message_{last_group_message.message_id}',
|
||
)
|
||
]
|
||
]
|
||
)
|
||
await callback.message.bot.send_message(
|
||
chat_id=callback.from_user.id, text=texts.t('ATTACHMENTS_SENT', 'Вложения отправлены.'), reply_markup=kb
|
||
)
|
||
except Exception:
|
||
pass
|
||
else:
|
||
try:
|
||
await callback.answer(texts.t('ATTACHMENTS_SENT', 'Вложения отправлены.'))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
async def user_delete_message(callback: types.CallbackQuery):
|
||
try:
|
||
msg_id = int(callback.data.replace('user_delete_message_', ''))
|
||
except ValueError:
|
||
await callback.answer('❌')
|
||
return
|
||
try:
|
||
await callback.message.bot.delete_message(chat_id=callback.from_user.id, message_id=msg_id)
|
||
await callback.message.delete()
|
||
except Exception:
|
||
pass
|
||
await callback.answer('✅')
|
||
|
||
|
||
async def _edit_or_send(
|
||
message: types.Message,
|
||
chat_id: int | None,
|
||
message_id: int | None,
|
||
text: str,
|
||
language: str,
|
||
) -> None:
|
||
"""Попытаться отредактировать prompt-сообщение, при неудаче — отправить новое."""
|
||
if chat_id and message_id:
|
||
try:
|
||
await message.bot.edit_message_text(
|
||
chat_id=chat_id,
|
||
message_id=message_id,
|
||
text=text,
|
||
reply_markup=get_ticket_cancel_keyboard(language),
|
||
)
|
||
return
|
||
except TelegramBadRequest:
|
||
pass
|
||
await message.answer(text, reply_markup=get_ticket_cancel_keyboard(language))
|
||
|
||
|
||
async def _try_delete_message_later(bot: Bot, chat_id: int, message_id: int, delay_seconds: float = 1.0):
|
||
try:
|
||
await asyncio.sleep(delay_seconds)
|
||
await bot.delete_message(chat_id=chat_id, message_id=message_id)
|
||
except Exception:
|
||
# В приватных чатах удаление сообщений пользователя может быть недоступно — игнорируем ошибки
|
||
pass
|
||
|
||
|
||
async def reply_to_ticket(callback: types.CallbackQuery, state: FSMContext, db_user: User):
|
||
"""Начать ответ на тикет"""
|
||
ticket_id = int(callback.data.replace('reply_ticket_', ''))
|
||
|
||
await state.update_data(ticket_id=ticket_id)
|
||
|
||
texts = get_texts(db_user.language)
|
||
|
||
try:
|
||
await callback.message.edit_text(
|
||
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
|
||
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
|
||
)
|
||
except TelegramBadRequest:
|
||
try:
|
||
await callback.message.delete()
|
||
except Exception:
|
||
pass
|
||
await callback.message.answer(
|
||
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
|
||
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
|
||
)
|
||
|
||
await state.set_state(TicketStates.waiting_for_reply)
|
||
await callback.answer()
|
||
|
||
|
||
async def handle_ticket_reply(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession):
|
||
# Проверяем, что пользователь в правильном состоянии
|
||
current_state = await state.get_state()
|
||
if current_state != TicketStates.waiting_for_reply:
|
||
return
|
||
|
||
# Защита от спама: по тикету принимаем только первое сообщение в коротком окне
|
||
try:
|
||
data_rl = await state.get_data()
|
||
rl_ticket_id = data_rl.get('ticket_id') or 'reply'
|
||
limited = await RateLimitCache.is_rate_limited(db_user.id, f'ticket_reply_{rl_ticket_id}', limit=1, window=2)
|
||
if limited:
|
||
try:
|
||
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
|
||
except Exception:
|
||
pass
|
||
return
|
||
except Exception:
|
||
pass
|
||
try:
|
||
data_rl = await state.get_data()
|
||
last_ts = data_rl.get('rl_ts_reply')
|
||
now_ts = time.time()
|
||
if last_ts and (now_ts - float(last_ts)) < 2:
|
||
try:
|
||
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
|
||
except Exception:
|
||
pass
|
||
return
|
||
await state.update_data(rl_ts_reply=now_ts)
|
||
except Exception:
|
||
pass
|
||
|
||
"""Обработать ответ на тикет"""
|
||
# Поддержка фото для ответа пользователя
|
||
# Ограничение ответа пользователя 500 символов
|
||
reply_text = (message.text or message.caption or '').strip()
|
||
# Строже режем до 400, чтобы учесть форматирование/смайлы
|
||
if len(reply_text) > 400:
|
||
reply_text = reply_text[:400]
|
||
media_type = None
|
||
media_file_id = None
|
||
media_caption = None
|
||
if message.photo:
|
||
media_type = 'photo'
|
||
media_file_id = message.photo[-1].file_id
|
||
media_caption = message.caption
|
||
|
||
if len(reply_text) < 5:
|
||
texts = get_texts(db_user.language)
|
||
await message.answer(
|
||
texts.t('TICKET_REPLY_TOO_SHORT', 'Ответ должен содержать минимум 5 символов. Попробуйте еще раз:')
|
||
)
|
||
return
|
||
|
||
data = await state.get_data()
|
||
ticket_id = data.get('ticket_id')
|
||
|
||
if not ticket_id:
|
||
texts = get_texts(db_user.language)
|
||
await message.answer(texts.t('TICKET_REPLY_ERROR', 'Ошибка: не найден ID тикета.'))
|
||
await state.clear()
|
||
return
|
||
|
||
try:
|
||
# Проверяем, что тикет принадлежит пользователю и не закрыт
|
||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||
if not ticket or ticket.user_id != db_user.id:
|
||
texts = get_texts(db_user.language)
|
||
await message.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.'))
|
||
await state.clear()
|
||
return
|
||
if ticket.status == TicketStatus.CLOSED.value:
|
||
texts = get_texts(db_user.language)
|
||
await message.answer(
|
||
texts.t('TICKET_CLOSED', '✅ Тикет закрыт.'),
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('CLOSE_NOTIFICATION', '❌ Закрыть уведомление'),
|
||
callback_data=f'close_ticket_notification_{ticket.id}',
|
||
)
|
||
]
|
||
]
|
||
),
|
||
)
|
||
await state.clear()
|
||
return
|
||
|
||
# Блокируем добавление сообщения, если тикет закрыт или заблокирован админом
|
||
if ticket.status == TicketStatus.CLOSED.value or ticket.is_user_reply_blocked:
|
||
texts = get_texts(db_user.language)
|
||
await message.answer(
|
||
texts.t('TICKET_CLOSED_NO_REPLY', '❌ Тикет закрыт, ответить невозможно.'),
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('CLOSE_NOTIFICATION', '❌ Закрыть уведомление'),
|
||
callback_data=f'close_ticket_notification_{ticket.id}',
|
||
)
|
||
]
|
||
]
|
||
),
|
||
)
|
||
await state.clear()
|
||
return
|
||
|
||
# Добавляем сообщение в тикет
|
||
await TicketMessageCRUD.add_message(
|
||
db,
|
||
ticket_id,
|
||
db_user.id,
|
||
reply_text,
|
||
is_from_admin=False,
|
||
media_type=media_type,
|
||
media_file_id=media_file_id,
|
||
media_caption=media_caption,
|
||
)
|
||
|
||
texts = get_texts(db_user.language)
|
||
|
||
await message.answer(
|
||
texts.t('TICKET_REPLY_SENT', '✅ Ваш ответ отправлен!'),
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('VIEW_TICKET', '👁️ Посмотреть тикет'), callback_data=f'view_ticket_{ticket_id}'
|
||
)
|
||
],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('BACK_TO_MENU', '🏠 В главное меню'), callback_data='back_to_menu'
|
||
)
|
||
],
|
||
]
|
||
),
|
||
)
|
||
|
||
await state.clear()
|
||
|
||
# Уведомить админов об ответе пользователя
|
||
logger.info('Attempting to notify admins about ticket reply #', ticket_id=ticket_id)
|
||
await notify_admins_about_ticket_reply(
|
||
ticket, reply_text, db, media_file_id=media_file_id, media_type=media_type
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error('Error adding ticket reply', error=e)
|
||
texts = get_texts(db_user.language)
|
||
await message.answer(
|
||
texts.t('TICKET_REPLY_ERROR', '❌ Произошла ошибка при отправке ответа. Попробуйте позже.')
|
||
)
|
||
|
||
|
||
async def close_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
"""Закрыть тикет"""
|
||
ticket_id = int(callback.data.replace('close_ticket_', ''))
|
||
|
||
try:
|
||
# Проверяем, что тикет принадлежит пользователю
|
||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||
if not ticket or ticket.user_id != db_user.id:
|
||
texts = get_texts(db_user.language)
|
||
await callback.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.'), show_alert=True)
|
||
return
|
||
|
||
# Запрещаем закрытие, если заблокирован для ответа? (не требуется) Закрываем тикет
|
||
success = await TicketCRUD.close_ticket(db, ticket_id)
|
||
|
||
if success:
|
||
texts = get_texts(db_user.language)
|
||
await callback.answer(texts.t('TICKET_CLOSED', '✅ Тикет закрыт.'), show_alert=True)
|
||
|
||
# Обновляем inline-клавиатуру текущего сообщения (убираем кнопки)
|
||
await callback.message.edit_reply_markup(
|
||
reply_markup=get_ticket_view_keyboard(ticket_id, True, db_user.language)
|
||
)
|
||
else:
|
||
texts = get_texts(db_user.language)
|
||
await callback.answer(texts.t('TICKET_CLOSE_ERROR', '❌ Ошибка при закрытии тикета.'), show_alert=True)
|
||
|
||
except Exception as e:
|
||
logger.error('Error closing ticket', error=e)
|
||
texts = get_texts(db_user.language)
|
||
await callback.answer(texts.t('TICKET_CLOSE_ERROR', '❌ Ошибка при закрытии тикета.'), show_alert=True)
|
||
|
||
|
||
async def cancel_ticket_creation(callback: types.CallbackQuery, state: FSMContext, db_user: User):
|
||
"""Отменить создание тикета"""
|
||
await state.clear()
|
||
|
||
texts = get_texts(db_user.language)
|
||
|
||
await callback.message.edit_text(
|
||
texts.t('TICKET_CREATION_CANCELLED', 'Создание тикета отменено.'),
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('BACK_TO_SUPPORT', '⬅️ К поддержке'), callback_data='menu_support'
|
||
)
|
||
]
|
||
]
|
||
),
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
async def cancel_ticket_reply(callback: types.CallbackQuery, state: FSMContext, db_user: User):
|
||
"""Отменить ответ на тикет"""
|
||
await state.clear()
|
||
|
||
texts = get_texts(db_user.language)
|
||
|
||
await callback.message.edit_text(
|
||
texts.t('TICKET_REPLY_CANCELLED', 'Ответ отменен.'),
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[types.InlineKeyboardButton(text=texts.t('BACK_TO_TICKETS', '⬅️ К тикетам'), callback_data='my_tickets')]
|
||
]
|
||
),
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
async def close_ticket_notification(callback: types.CallbackQuery, db_user: User):
|
||
"""Закрыть уведомление о тикете"""
|
||
texts = get_texts(db_user.language)
|
||
|
||
# Проверяем, доступно ли сообщение для удаления
|
||
if isinstance(callback.message, InaccessibleMessage):
|
||
await callback.answer()
|
||
return
|
||
|
||
try:
|
||
await callback.message.delete()
|
||
except TelegramBadRequest:
|
||
# Message is too old to delete (>48h) — edit it instead
|
||
try:
|
||
await callback.message.edit_text(texts.t('NOTIFICATION_CLOSED', 'Уведомление закрыто.'))
|
||
except TelegramBadRequest:
|
||
pass
|
||
await callback.answer(texts.t('NOTIFICATION_CLOSED', 'Уведомление закрыто.'))
|
||
|
||
|
||
async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
|
||
"""Уведомить админов о новом тикете"""
|
||
try:
|
||
from app.config import settings
|
||
|
||
if not settings.is_admin_notifications_enabled():
|
||
logger.info(
|
||
'Admin notifications disabled. Ticket # created by user', ticket_id=ticket.id, user_id=ticket.user_id
|
||
)
|
||
return
|
||
|
||
get_texts(settings.DEFAULT_LANGUAGE)
|
||
title = (ticket.title or '').strip()
|
||
if len(title) > 60:
|
||
title = title[:57] + '...'
|
||
|
||
try:
|
||
user = await get_user_by_id(db, ticket.user_id)
|
||
except Exception:
|
||
user = None
|
||
full_name = html.escape(user.full_name or '') if user else 'Unknown'
|
||
telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else '—'
|
||
username_display = html.escape((user.username or 'отсутствует') if user else 'отсутствует')
|
||
|
||
# Загружаем первое сообщение для получения медиа и превью текста
|
||
first_message = await TicketMessageCRUD.get_first_message(db, ticket.id)
|
||
media_file_id = None
|
||
media_type = None
|
||
message_preview = ''
|
||
if first_message:
|
||
media_file_id = first_message.media_file_id if first_message.has_media else None
|
||
media_type = first_message.media_type if first_message.has_media else None
|
||
msg_text = (first_message.message_text or '').strip()
|
||
if msg_text:
|
||
message_preview = msg_text[:200] + '...' if len(msg_text) > 200 else msg_text
|
||
|
||
safe_title = html.escape(title) if title else '—'
|
||
|
||
notification_text = (
|
||
f'🎫 <b>НОВЫЙ ТИКЕТ</b>\n\n'
|
||
f'🆔 <b>ID:</b> <code>{ticket.id}</code>\n'
|
||
f'👤 <b>Пользователь:</b> {full_name}\n'
|
||
f'🆔 <b>ID:</b> <code>{telegram_id_display}</code>\n'
|
||
f'📱 <b>Username:</b> @{username_display}\n'
|
||
f'📝 <b>Заголовок:</b> {safe_title}\n'
|
||
)
|
||
|
||
if message_preview:
|
||
notification_text += f'\n📩 <b>Сообщение:</b>\n{html.escape(message_preview)}\n'
|
||
|
||
notification_text += f'\n📅 <b>Создан:</b> {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n'
|
||
|
||
from app.services.maintenance_service import maintenance_service
|
||
|
||
bot = maintenance_service._bot or None
|
||
if bot is None:
|
||
logger.warning('Bot instance is not available for admin notifications')
|
||
return
|
||
|
||
service = AdminNotificationService(bot)
|
||
await service.send_ticket_event_notification(
|
||
notification_text, None, media_file_id=media_file_id, media_type=media_type
|
||
)
|
||
except Exception as e:
|
||
logger.error('Error notifying admins about new ticket', error=e)
|
||
|
||
|
||
async def notify_admins_about_ticket_reply(
|
||
ticket: Ticket,
|
||
reply_text: str,
|
||
db: AsyncSession,
|
||
*,
|
||
media_file_id: str | None = None,
|
||
media_type: str | None = None,
|
||
):
|
||
"""Уведомить админов об ответе пользователя на тикет"""
|
||
logger.info('notify_admins_about_ticket_reply called for ticket #', ticket_id=ticket.id)
|
||
try:
|
||
from app.config import settings
|
||
|
||
if not settings.is_admin_notifications_enabled():
|
||
logger.info('Admin notifications disabled. Reply to ticket #', ticket_id=ticket.id)
|
||
return
|
||
|
||
title = (ticket.title or '').strip()
|
||
if len(title) > 60:
|
||
title = title[:57] + '...'
|
||
|
||
try:
|
||
user = await get_user_by_id(db, ticket.user_id)
|
||
except Exception:
|
||
user = None
|
||
full_name = html.escape(user.full_name or '') if user else 'Unknown'
|
||
telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else '—'
|
||
username_display = html.escape((user.username or 'отсутствует') if user else 'отсутствует')
|
||
|
||
reply_preview = reply_text[:200] + '...' if len(reply_text) > 200 else reply_text
|
||
safe_title = html.escape(title) if title else '—'
|
||
|
||
notification_text = (
|
||
f'💬 <b>ОТВЕТ НА ТИКЕТ</b>\n\n'
|
||
f'🆔 <b>ID тикета:</b> <code>{ticket.id}</code>\n'
|
||
f'📝 <b>Заголовок:</b> {safe_title}\n'
|
||
f'👤 <b>Пользователь:</b> {full_name}\n'
|
||
f'🆔 <b>ID:</b> <code>{telegram_id_display}</code>\n'
|
||
f'📱 <b>Username:</b> @{username_display}\n\n'
|
||
f'📩 <b>Сообщение:</b>\n{html.escape(reply_preview)}\n'
|
||
)
|
||
|
||
from app.services.maintenance_service import maintenance_service
|
||
|
||
bot = maintenance_service._bot or None
|
||
if bot is None:
|
||
logger.warning('Bot instance is not available for admin notifications')
|
||
return
|
||
|
||
service = AdminNotificationService(bot)
|
||
result = await service.send_ticket_event_notification(
|
||
notification_text, None, media_file_id=media_file_id, media_type=media_type
|
||
)
|
||
logger.info('Ticket # reply notification sent', ticket_id=ticket.id, result=result)
|
||
except Exception as e:
|
||
logger.error('Error notifying admins about ticket reply', error=e)
|
||
|
||
|
||
def register_handlers(dp: Dispatcher):
|
||
"""Регистрация обработчиков тикетов"""
|
||
|
||
# Создание тикета (теперь без приоритета)
|
||
dp.callback_query.register(show_ticket_priority_selection, F.data == 'create_ticket')
|
||
|
||
dp.message.register(handle_ticket_title_input, TicketStates.waiting_for_title)
|
||
|
||
dp.message.register(handle_ticket_message_input, TicketStates.waiting_for_message)
|
||
|
||
# Просмотр тикетов
|
||
dp.callback_query.register(show_my_tickets, F.data == 'my_tickets')
|
||
dp.callback_query.register(show_my_tickets_closed, F.data == 'my_tickets_closed')
|
||
dp.callback_query.register(show_my_tickets_closed, F.data.startswith('my_tickets_closed_page_'))
|
||
|
||
dp.callback_query.register(view_ticket, F.data.startswith('view_ticket_') | F.data.startswith('ticket_view_page_'))
|
||
|
||
# Вложения пользователя
|
||
dp.callback_query.register(send_ticket_attachments, F.data.startswith('ticket_attachments_'))
|
||
|
||
dp.callback_query.register(user_delete_message, F.data.startswith('user_delete_message_'))
|
||
|
||
# Ответы на тикеты
|
||
dp.callback_query.register(reply_to_ticket, F.data.startswith('reply_ticket_'))
|
||
|
||
dp.message.register(handle_ticket_reply, TicketStates.waiting_for_reply)
|
||
|
||
# Закрытие тикетов
|
||
dp.callback_query.register(close_ticket, F.data.regexp(r'^close_ticket_\d+$'))
|
||
|
||
# Отмена операций
|
||
dp.callback_query.register(cancel_ticket_creation, F.data == 'cancel_ticket_creation')
|
||
|
||
dp.callback_query.register(cancel_ticket_reply, F.data == 'cancel_ticket_reply')
|
||
|
||
# Пагинация тикетов
|
||
dp.callback_query.register(show_my_tickets, F.data.startswith('my_tickets_page_'))
|
||
|
||
# Закрытие уведомлений
|
||
dp.callback_query.register(close_ticket_notification, F.data.startswith('close_ticket_notification_'))
|