Files
remnawave-bedolaga-telegram…/app/handlers/tickets.py
T
Fringg 9de34900a2 fix: comprehensive html.escape() for all user/admin data in Telegram HTML messages
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
2026-03-23 08:06:15 +03:00

1155 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_'))