Merge pull request #2211 from BEDOLAGA-DEV/dev5

Dev5
This commit is contained in:
Egor
2025-12-25 09:45:07 +03:00
committed by GitHub
6 changed files with 415 additions and 81 deletions

View File

@@ -711,29 +711,56 @@ async def handle_media_selection(
await state.update_data(has_media=False)
await show_button_selector_callback(callback, db_user, state)
return
media_type = callback.data.replace('add_media_', '')
media_instructions = {
"photo": "📷 Отправьте фотографию для рассылки:",
"video": "🎥 Отправьте видео для рассылки:",
"document": "📄 Отправьте документ для рассылки:"
}
await state.update_data(
media_type=media_type,
waiting_for_media=True
)
await callback.message.edit_text(
instruction_text = (
f"{media_instructions.get(media_type, 'Отправьте медиафайл:')}\n\n"
f"<i>Размер файла не должен превышать 50 МБ</i>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
]),
parse_mode="HTML"
f"<i>Размер файла не должен превышать 50 МБ</i>"
)
instruction_keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
])
# Проверяем, является ли текущее сообщение медиа-сообщением
is_media_message = (
callback.message.photo
or callback.message.video
or callback.message.document
or callback.message.animation
or callback.message.audio
or callback.message.voice
)
if is_media_message:
# Удаляем медиа-сообщение и отправляем новое текстовое
try:
await callback.message.delete()
except Exception:
pass
await callback.message.answer(
instruction_text,
reply_markup=instruction_keyboard,
parse_mode="HTML"
)
else:
await callback.message.edit_text(
instruction_text,
reply_markup=instruction_keyboard,
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_broadcast_media)
await callback.answer()
@@ -860,12 +887,12 @@ async def show_button_selector_callback(
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
await state.update_data(selected_buttons=selected_buttons)
media_info = ""
if has_media:
media_type = data.get('media_type', 'файл')
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type} добавлен"
text = f"""
📘 <b>Выбор дополнительных кнопок</b>
@@ -882,16 +909,39 @@ async def show_button_selector_callback(
Выберите нужные кнопки и нажмите "Продолжить":
"""
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
selected_buttons, has_media, db_user.language
)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML"
# Проверяем, является ли текущее сообщение медиа-сообщением
# (фото, видео, документ и т.д.) - для них нельзя использовать edit_text
is_media_message = (
callback.message.photo
or callback.message.video
or callback.message.document
or callback.message.animation
or callback.message.audio
or callback.message.voice
)
if is_media_message:
# Удаляем медиа-сообщение и отправляем новое текстовое
try:
await callback.message.delete()
except Exception:
pass # Игнорируем ошибки удаления
await callback.message.answer(
text,
reply_markup=keyboard,
parse_mode="HTML"
)
else:
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()

View File

@@ -26,8 +26,67 @@ from app.utils.cache import RateLimitCache
logger = logging.getLogger(__name__)
# Максимальная длина сообщения Telegram (с запасом)
MAX_MESSAGE_LEN = 3500
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 = MAX_MESSAGE_LEN) -> 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 if pages else [header]
async def show_admin_tickets(
@@ -133,15 +192,27 @@ async def view_admin_ticket(
state: Optional[FSMContext] = None,
ticket_id: Optional[int] = None
):
"""Показать детали тикета для админа"""
"""Показать детали тикета для админа с пагинацией"""
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
texts = get_texts(db_user.language)
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
if ticket_id is None:
# Парсим ticket_id и page из callback_data
page = 1
data_str = callback.data or ""
if data_str.startswith("admin_ticket_page_"):
# format: admin_ticket_page_{ticket_id}_{page}
try:
ticket_id = int((callback.data or "").split("_")[-1])
parts = data_str.split("_")
ticket_id = int(parts[3])
page = max(1, int(parts[4]))
except (ValueError, IndexError):
pass
elif ticket_id is None:
try:
ticket_id = int(data_str.split("_")[-1])
except (ValueError, AttributeError):
texts = get_texts(db_user.language)
await callback.answer(
@@ -152,9 +223,9 @@ async def view_admin_ticket(
if state is None:
state = FSMContext(callback.bot, callback.from_user.id)
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True)
if not ticket:
texts = get_texts(db_user.language)
await callback.answer(
@@ -162,59 +233,60 @@ async def view_admin_ticket(
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)
user_name = ticket.user.full_name if ticket.user else "Unknown"
telegram_id_display = ticket.user.telegram_id if ticket.user else ""
username_value = ticket.user.username if ticket.user else None
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
ticket_text += f"👤 Пользователь: {user_name}\n"
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
header = f"🎫 Тикет #{ticket.id}\n\n"
header += f"👤 Пользователь: {user_name}\n"
header += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
if username_value:
safe_username = html.escape(username_value)
ticket_text += f"📱 Username: @{safe_username}\n"
ticket_text += (
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
f"tg://resolve?domain={safe_username}</a>\n"
)
header += f"📱 Username: @{safe_username}\n"
else:
ticket_text += "📱 Username: отсутствует\n"
if ticket.user and ticket.user.telegram_id:
chat_link = f"tg://user?id={int(ticket.user.telegram_id)}"
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
ticket_text += "\n"
ticket_text += f"📝 Заголовок: {ticket.title}\n"
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
ticket_text += f"🔄 Обновлен: {ticket.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
header += "📱 Username: отсутствует\n"
header += f"📝 Заголовок: {ticket.title}\n"
header += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
header += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
if ticket.is_user_reply_blocked:
if ticket.user_reply_block_permanent:
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
header += "🚫 Пользователь заблокирован навсегда\n\n"
elif ticket.user_reply_block_until:
ticket_text += f"⏳ Блок до: {ticket.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n"
header += f"⏳ Блок до: {ticket.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n\n"
# Формируем блоки сообщений
message_blocks: list[str] = []
if ticket.messages:
ticket_text += f"💬 Сообщения ({len(ticket.messages)}):\n\n"
message_blocks.append(f"💬 Сообщения ({len(ticket.messages)}):\n\n")
for msg in ticket.messages:
sender = "👤 Пользователь" if msg.is_user_message else "🛠️ Поддержка"
ticket_text += f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n"
ticket_text += f"{msg.message_text}\n\n"
block = (
f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n"
f"{msg.message_text}\n\n"
)
if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo":
ticket_text += "📎 Вложение: фото\n\n"
# Добавим кнопку "Вложения", если есть фото
block += "📎 Вложение: фото\n\n"
message_blocks.append(block)
# Разбиваем на страницы
pages = _split_text_into_pages(header, message_blocks, max_len=MAX_MESSAGE_LEN)
total_pages = len(pages)
if page > total_pages:
page = total_pages
# Формируем клавиатуру
has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in ticket.messages or [])
keyboard = get_admin_ticket_view_keyboard(
ticket_id,
@@ -222,7 +294,8 @@ async def view_admin_ticket(
db_user.language,
is_user_blocked=ticket.is_user_reply_blocked
)
# Кнопка открытия профиля пользователя в админке
# Кнопка профиля пользователя
try:
if ticket.user:
admin_profile_btn = types.InlineKeyboardButton(
@@ -232,34 +305,76 @@ async def view_admin_ticket(
keyboard.inline_keyboard.insert(0, [admin_profile_btn])
except Exception:
pass
# Кнопки ЛС и профиль
try:
if ticket.user and ticket.user.telegram_id and ticket.user.username:
safe_username = html.escape(ticket.user.username)
buttons_row = []
pm_url = f"tg://resolve?domain={safe_username}"
buttons_row.append(types.InlineKeyboardButton(text=" Написать в ЛС", url=pm_url))
buttons_row.append(types.InlineKeyboardButton(text="✉ ЛС", url=pm_url))
profile_url = f"tg://user?id={ticket.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
if buttons_row:
keyboard.inline_keyboard.insert(0, buttons_row)
except Exception:
pass
# Кнопка вложений
if has_photos:
try:
keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{ticket_id}")])
keyboard.inline_keyboard.insert(0, [
types.InlineKeyboardButton(
text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"),
callback_data=f"admin_ticket_attachments_{ticket_id}"
)
])
except Exception:
pass
# Рендер через фото-утилиту (с логотипом), внутри есть фоллбеки на текст
from app.utils.photo_message import edit_or_answer_photo
await edit_or_answer_photo(
callback=callback,
caption=ticket_text,
keyboard=keyboard,
parse_mode="HTML",
)
# сохраняем id для дальнейших действий (ответ/статусы)
# Пагинация
if total_pages > 1:
nav_row = []
if page > 1:
nav_row.append(types.InlineKeyboardButton(
text="⬅️",
callback_data=f"admin_ticket_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"admin_ticket_page_{ticket_id}_{page + 1}"
))
try:
keyboard.inline_keyboard.insert(0, nav_row)
except Exception:
pass
page_text = pages[page - 1]
# Отправка сообщения
try:
await callback.message.edit_text(
page_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except TelegramBadRequest:
try:
await callback.message.delete()
except Exception:
pass
await callback.message.answer(
page_text,
reply_markup=keyboard,
parse_mode="HTML"
)
# Сохраняем id для дальнейших действий
if state is not None:
try:
await state.update_data(ticket_id=ticket_id)
@@ -1034,7 +1149,8 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(close_all_open_admin_tickets, F.data == "admin_tickets_close_all_open")
dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_"))
dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_ticket_page_"))
# Ответы на тикеты
dp.callback_query.register(
reply_to_admin_ticket,

View File

@@ -68,6 +68,8 @@ from app.services.trial_activation_service import (
rollback_trial_subscription_activation,
)
logger = logging.getLogger(__name__)
def _serialize_markup(markup: Optional[InlineKeyboardMarkup]) -> Optional[Any]:
if markup is None:
@@ -211,7 +213,13 @@ async def show_subscription_info(
subscription_service = SubscriptionService()
await subscription_service.sync_subscription_usage(db, subscription)
# Проверяем и синхронизируем подписку с RemnaWave если необходимо
sync_success, sync_error = await subscription_service.ensure_subscription_synced(db, subscription)
if not sync_success:
logger.warning(f"Не удалось синхронизировать подписку {subscription.id} с RemnaWave: {sync_error}")
await db.refresh(subscription)
await db.refresh(db_user)
current_time = datetime.utcnow()
@@ -2051,17 +2059,32 @@ async def confirm_purchase(
promo_offer_discount_percent = 0
# Валидация: проверяем что cached_total_price соответствует ожидаемой финальной цене
# Допускаем небольшое расхождение из-за округления
is_valid = True
if abs(final_price - cached_total_price) > 100: # допуск 1
# Если есть расхождение, логируем предупреждение но продолжаем с пересчитанной ценой
# Допускаем небольшое расхождение из-за округления (до 5%)
price_difference = abs(final_price - cached_total_price)
max_allowed_difference = max(500, int(final_price * 0.05)) # 5% или минимум 5
if price_difference > max_allowed_difference:
# Слишком большое расхождение - блокируем покупку
logger.error(
f"Критическое расхождение цены для пользователя {db_user.telegram_id}: "
f"кэш={cached_total_price/100}₽, пересчет={final_price/100}₽, "
f"разница={price_difference/100}₽ (>{max_allowed_difference/100}₽). "
f"Покупка заблокирована."
)
await callback.answer(
"Цена изменилась. Пожалуйста, начните оформление заново.",
show_alert=True
)
return
elif price_difference > 100: # допуск 1₽
# Небольшое расхождение - логируем предупреждение но продолжаем
logger.warning(
f"Расхождение цены для пользователя {db_user.telegram_id}: "
f"кэш={cached_total_price/100}₽, пересчет={final_price/100}₽. "
f"Используем пересчитанную цену."
)
# Используем пересчитанную цену вместо закэшированной для надежности
# Используем пересчитанную цену
validation_total_price = calculated_total_before_promo
logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):")

View File

@@ -464,17 +464,61 @@ async def show_my_tickets_closed(
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(current) + len(block) > max_len:
pages.append(current)
# Если блок сам по себе слишком длинный — разбиваем его
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 if pages else [header]

View File

@@ -373,13 +373,16 @@ class MiniAppSubscriptionPurchaseService:
period_map: Dict[str, PurchasePeriodConfig] = {}
default_devices = settings.DEFAULT_DEVICE_LIMIT
if subscription and getattr(subscription, "device_limit", None):
# Для триала НЕ используем его ограничения как дефолтные,
# чтобы при продлении клиент получил стандартные значения платной подписки
is_trial_subscription = subscription and getattr(subscription, "is_trial", False)
if subscription and getattr(subscription, "device_limit", None) and not is_trial_subscription:
default_devices = max(default_devices, int(subscription.device_limit))
fixed_traffic_value = None
if settings.is_traffic_fixed():
fixed_traffic_value = settings.get_fixed_traffic_limit()
elif subscription and subscription.traffic_limit_gb is not None:
elif subscription and subscription.traffic_limit_gb is not None and not is_trial_subscription:
fixed_traffic_value = subscription.traffic_limit_gb
default_period_days = available_periods[0] if available_periods else 30

View File

@@ -465,7 +465,105 @@ class SubscriptionService:
except Exception as e:
logger.error(f"Ошибка синхронизации трафика: {e}")
return False
async def ensure_subscription_synced(
self,
db: AsyncSession,
subscription: Subscription,
) -> Tuple[bool, Optional[str]]:
"""
Проверяет и синхронизирует подписку с RemnaWave при необходимости.
Если subscription_url отсутствует или данные не синхронизированы,
пытается обновить/создать пользователя в RemnaWave.
Returns:
Tuple[bool, Optional[str]]: (успех, сообщение об ошибке)
"""
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
logger.error(f"Пользователь не найден для подписки {subscription.id}")
return False, "user_not_found"
# Проверяем, нужна ли синхронизация
needs_sync = (
not subscription.subscription_url
or not user.remnawave_uuid
)
if not needs_sync:
# Проверяем, существует ли пользователь в RemnaWave
try:
async with self.get_api_client() as api:
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
if not remnawave_user:
needs_sync = True
logger.warning(
f"Пользователь {user.remnawave_uuid} не найден в RemnaWave, требуется синхронизация"
)
except Exception as check_error:
logger.warning(f"Не удалось проверить пользователя в RemnaWave: {check_error}")
# Продолжаем, возможно проблема временная
if not needs_sync:
return True, None
logger.info(
f"Синхронизация подписки {subscription.id} с RemnaWave "
f"(subscription_url={bool(subscription.subscription_url)}, "
f"remnawave_uuid={bool(user.remnawave_uuid)})"
)
# Пытаемся синхронизировать
result = None
if user.remnawave_uuid:
# Пробуем обновить существующего пользователя
result = await self.update_remnawave_user(
db,
subscription,
reset_traffic=False,
)
# Если update не удался (пользователь удалён из RemnaWave) — пробуем создать
if not result:
logger.warning(
f"Не удалось обновить пользователя {user.remnawave_uuid} в RemnaWave, "
f"пробуем создать заново"
)
# Сбрасываем старый UUID, create_remnawave_user установит новый
user.remnawave_uuid = None
result = await self.create_remnawave_user(
db,
subscription,
reset_traffic=False,
)
else:
# Создаём нового пользователя
result = await self.create_remnawave_user(
db,
subscription,
reset_traffic=False,
)
if result:
await db.refresh(subscription)
await db.refresh(user)
logger.info(
f"Подписка {subscription.id} успешно синхронизирована с RemnaWave. "
f"URL: {subscription.subscription_url}"
)
return True, None
else:
logger.error(f"Не удалось синхронизировать подписку {subscription.id} с RemnaWave")
return False, "sync_failed"
except RemnaWaveAPIError as api_error:
logger.error(f"Ошибка RemnaWave API при синхронизации подписки {subscription.id}: {api_error}")
return False, "api_error"
except Exception as e:
logger.error(f"Ошибка синхронизации подписки {subscription.id}: {e}")
return False, "unknown_error"
async def calculate_subscription_price(
self,
period_days: int,