9de34900a2
Bot uses default HTML parse mode — all messages are HTML-parsed by Telegram. Added html.escape() to all user-controlled and admin-controlled strings before interpolation into HTML messages to prevent injection and parse errors. 49 files, ~250+ injection points fixed: - user.full_name, first_name across all handlers and services - tariff.name/description in purchase flow, admin panel, auto-purchase service - campaign.name, start_parameter in admin and user-facing handlers - group.name, promo_group.name across promo management - contest.title, prize_text, leaderboard names (including public channels) - transaction.description (contains raw user.full_name from referral service) - restriction_reason across all balance and subscription handlers - ticket.title, message_text, poll.title, poll.description - welcome text template placeholders (first_name, username) - maintenance reason, admin_name, selected_prize.display_name New helpers in app/utils/formatting.py: - safe_html_name() for escaping display names - user_html_link() replacing 15+ duplicated inline link patterns
1155 lines
48 KiB
Python
1155 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
|
||
|
||
await callback.message.delete()
|
||
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_'))
|