From abb156a7b18ef5ddef5a0bfd5aba98461bcfdd9d Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 4 Oct 2025 16:12:03 +0300 Subject: [PATCH] Fix RemnaWave config refresh in subscription service --- app/handlers/admin/promo_offers.py | 289 ++++++++++++++++++++++++++- app/handlers/subscription.py | 17 +- app/services/promo_offer_service.py | 45 ++++- app/services/subscription_service.py | 46 ++++- locales/en.json | 14 +- locales/ru.json | 14 +- 6 files changed, 398 insertions(+), 27 deletions(-) diff --git a/app/handlers/admin/promo_offers.py b/app/handlers/admin/promo_offers.py index 1f308312..a545d665 100644 --- a/app/handlers/admin/promo_offers.py +++ b/app/handlers/admin/promo_offers.py @@ -11,6 +11,11 @@ from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMar from sqlalchemy.ext.asyncio import AsyncSession from app.database.crud.discount_offer import upsert_discount_offer +from app.database.crud.server_squad import ( + get_all_server_squads, + get_server_squad_by_id, + get_server_squad_by_uuid, +) from app.database.crud.promo_offer_template import ( ensure_default_templates, get_promo_offer_template_by_id, @@ -26,6 +31,9 @@ from app.utils.decorators import admin_required, error_handler logger = logging.getLogger(__name__) +SQUADS_PAGE_LIMIT = 10 + + OFFER_TYPE_CONFIG = { "test_access": { "icon": "🧪", @@ -280,12 +288,154 @@ async def prompt_edit_duration(callback: CallbackQuery, db_user: User, db: Async @error_handler async def prompt_edit_squads(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): template_id = int(callback.data.split("_")[-1]) - texts = get_texts(db_user.language) - prompt = texts.t( - "ADMIN_PROMO_OFFER_PROMPT_SQUADS", - "Перечислите UUID сквадов через запятую или пробел. Для очистки отправьте 'clear':", + template = await get_promo_offer_template_by_id(db, template_id) + if not template: + await callback.answer("❌ Предложение не найдено", show_alert=True) + return + + await state.update_data( + selected_promo_offer=template.id, + promo_edit_message_id=callback.message.message_id, + promo_edit_chat_id=callback.message.chat.id, + ) + + await _render_squad_selection(callback, template, db, db_user.language) + await callback.answer() + + +async def _render_squad_selection( + callback: CallbackQuery, + template: PromoOfferTemplate, + db: AsyncSession, + language: str, + page: int = 1, +): + texts = get_texts(language) + + squads, total_count = await get_all_server_squads( + db, + available_only=False, + page=page, + limit=SQUADS_PAGE_LIMIT, + ) + + if total_count == 0: + await callback.message.edit_text( + texts.t("ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE", "❌ Доступные серверы не найдены."), + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_offer_squad_back_{template.id}")]] + ), + ) + return + + selected_uuid = None + if template.test_squad_uuids: + selected_uuid = str(template.test_squad_uuids[0]) + + selected_server_name = None + if selected_uuid: + selected_server = next((srv for srv in squads if srv.squad_uuid == selected_uuid), None) + if not selected_server: + selected_server = await get_server_squad_by_uuid(db, selected_uuid) + if selected_server: + selected_server_name = selected_server.display_name + + header = texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE", "🌍 Выберите сквад") + if selected_server_name: + current = texts.t( + "ADMIN_PROMO_OFFER_SELECTED_SQUAD", + "Текущий сквад: {name}", + ).format(name=selected_server_name) + elif selected_uuid: + current = texts.t( + "ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID", + "Текущий сквад: {uuid}", + ).format(uuid=selected_uuid) + else: + current = texts.t( + "ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY", + "Текущий сквад: не выбран", + ) + + hint = texts.t( + "ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT", + "Выберите сервер для тестового доступа из списка ниже.", + ) + + total_pages = (total_count + SQUADS_PAGE_LIMIT - 1) // SQUADS_PAGE_LIMIT or 1 + page = max(1, min(page, total_pages)) + + lines = [header, "", current, "", hint] + if total_pages > 1: + lines.append( + texts.t( + "ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE", + "Страница {page}/{total}", + ).format(page=page, total=total_pages) + ) + + text = "\n".join(lines) + + keyboard_rows: List[List[InlineKeyboardButton]] = [] + for server in squads: + emoji = "✅" if server.squad_uuid == selected_uuid else ("⚪" if server.is_available else "🔒") + label = f"{emoji} {server.display_name}" + keyboard_rows.append([ + InlineKeyboardButton( + text=label, + callback_data=f"promo_offer_select_squad_{template.id}_{server.id}_{page}", + ) + ]) + + if total_pages > 1: + nav_row: List[InlineKeyboardButton] = [] + if page > 1: + nav_row.append( + InlineKeyboardButton( + text="⬅️", + callback_data=f"promo_offer_squad_page_{template.id}_{page - 1}", + ) + ) + if page < total_pages: + nav_row.append( + InlineKeyboardButton( + text="➡️", + callback_data=f"promo_offer_squad_page_{template.id}_{page + 1}", + ) + ) + if nav_row: + keyboard_rows.append(nav_row) + + action_row = [ + InlineKeyboardButton( + text=texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR", "🗑 Очистить"), + callback_data=f"promo_offer_clear_squad_{template.id}_{page}", + ), + InlineKeyboardButton( + text=texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK", "↩️ Назад"), + callback_data=f"promo_offer_squad_back_{template.id}", + ), + ] + keyboard_rows.append(action_row) + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_rows), + parse_mode="HTML", + ) + + +async def _render_offer_details( + callback: CallbackQuery, + template: PromoOfferTemplate, + language: str, +): + description = _describe_offer(template, language) + await callback.message.edit_text( + description, + reply_markup=_build_offer_detail_keyboard(template, language), + parse_mode="HTML", ) - await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_squads) async def _handle_edit_field( @@ -488,8 +638,128 @@ async def process_edit_test_duration(message: Message, state: FSMContext, db: As await _handle_edit_field(message, state, db, db_user, "test_duration_hours") -async def process_edit_test_squads(message: Message, state: FSMContext, db: AsyncSession, db_user: User): - await _handle_edit_field(message, state, db, db_user, "test_squad_uuids") +@admin_required +@error_handler +async def paginate_squad_selection(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + try: + prefix = "promo_offer_squad_page_" + if not callback.data.startswith(prefix): + raise ValueError("invalid prefix") + payload = callback.data[len(prefix):] + template_id_str, page_str = payload.split("_", 1) + template_id = int(template_id_str) + page = int(page_str) + except (ValueError, AttributeError): + await callback.answer("❌ Некорректные данные", show_alert=True) + return + + template = await get_promo_offer_template_by_id(db, template_id) + if not template: + await callback.answer("❌ Предложение не найдено", show_alert=True) + return + + await state.update_data(selected_promo_offer=template.id) + await _render_squad_selection(callback, template, db, db_user.language, page=page) + await callback.answer() + + +@admin_required +@error_handler +async def select_squad_for_template(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + try: + prefix = "promo_offer_select_squad_" + if not callback.data.startswith(prefix): + raise ValueError("invalid prefix") + payload = callback.data[len(prefix):] + template_id_str, server_id_str, page_str = payload.split("_", 2) + template_id = int(template_id_str) + server_id = int(server_id_str) + page = int(page_str) + except (ValueError, AttributeError): + await callback.answer("❌ Некорректные данные", show_alert=True) + return + + template = await get_promo_offer_template_by_id(db, template_id) + if not template: + await callback.answer("❌ Предложение не найдено", show_alert=True) + return + + server = await get_server_squad_by_id(db, server_id) + if not server: + await callback.answer( + get_texts(db_user.language).t( + "ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND", + "❌ Сервер не найден", + ), + show_alert=True, + ) + return + + await update_promo_offer_template(db, template, test_squad_uuids=[server.squad_uuid]) + updated = await get_promo_offer_template_by_id(db, template.id) + if updated: + await state.update_data(selected_promo_offer=updated.id) + + texts = get_texts(db_user.language) + await callback.answer(texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED", "✅ Сквад обновлён")) + + if updated: + await _render_offer_details(callback, updated, db_user.language) + else: + await _render_squad_selection(callback, template, db, db_user.language, page=page) + + +@admin_required +@error_handler +async def clear_squad_for_template(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + try: + prefix = "promo_offer_clear_squad_" + if not callback.data.startswith(prefix): + raise ValueError("invalid prefix") + payload = callback.data[len(prefix):] + template_id_str, page_str = payload.split("_", 1) + template_id = int(template_id_str) + page = int(page_str) + except (ValueError, AttributeError): + await callback.answer("❌ Некорректные данные", show_alert=True) + return + + template = await get_promo_offer_template_by_id(db, template_id) + if not template: + await callback.answer("❌ Предложение не найдено", show_alert=True) + return + + await update_promo_offer_template(db, template, test_squad_uuids=[]) + updated = await get_promo_offer_template_by_id(db, template.id) + if updated: + await state.update_data(selected_promo_offer=updated.id) + + texts = get_texts(db_user.language) + await callback.answer(texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED", "✅ Сквад очищен")) + + if updated: + await _render_squad_selection(callback, updated, db, db_user.language, page=page) + else: + await _render_squad_selection(callback, template, db, db_user.language, page=page) + + +@admin_required +@error_handler +async def back_to_offer_from_squads(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + try: + template_id = int(callback.data.split("_")[-1]) + except (ValueError, AttributeError): + await callback.answer("❌ Некорректные данные", show_alert=True) + return + + template = await get_promo_offer_template_by_id(db, template_id) + if not template: + await callback.answer("❌ Предложение не найдено", show_alert=True) + return + + await state.update_data(selected_promo_offer=template.id) + await _render_offer_details(callback, template, db_user.language) + await callback.answer() def register_handlers(dp: Dispatcher): @@ -500,6 +770,10 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(prompt_edit_discount, F.data.startswith("promo_offer_edit_discount_")) dp.callback_query.register(prompt_edit_duration, F.data.startswith("promo_offer_edit_duration_")) dp.callback_query.register(prompt_edit_squads, F.data.startswith("promo_offer_edit_squads_")) + dp.callback_query.register(paginate_squad_selection, F.data.startswith("promo_offer_squad_page_")) + dp.callback_query.register(select_squad_for_template, F.data.startswith("promo_offer_select_squad_")) + dp.callback_query.register(clear_squad_for_template, F.data.startswith("promo_offer_clear_squad_")) + dp.callback_query.register(back_to_offer_from_squads, F.data.startswith("promo_offer_squad_back_")) dp.callback_query.register(show_send_segments, F.data.startswith("promo_offer_send_menu_")) dp.callback_query.register(send_offer_to_segment, F.data.startswith("promo_offer_send_")) dp.callback_query.register(show_promo_offer_details, F.data.startswith("promo_offer_")) @@ -509,4 +783,3 @@ def register_handlers(dp: Dispatcher): dp.message.register(process_edit_valid_hours, AdminStates.editing_promo_offer_valid_hours) dp.message.register(process_edit_discount_percent, AdminStates.editing_promo_offer_discount) dp.message.register(process_edit_test_duration, AdminStates.editing_promo_offer_test_duration) - dp.message.register(process_edit_test_squads, AdminStates.editing_promo_offer_squads) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 2e5280bd..230340f5 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -5195,6 +5195,11 @@ async def claim_discount_offer( "TEST_ACCESS_NO_SQUADS", "❌ Не удалось определить список серверов для теста. Обратитесь к администратору.", ) + elif error_code == "remnawave_sync_failed": + error_message = texts.get( + "TEST_ACCESS_REMNAWAVE_ERROR", + "❌ Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.", + ) else: error_message = texts.get( "TEST_ACCESS_UNKNOWN_ERROR", @@ -5213,7 +5218,17 @@ async def claim_discount_offer( popup_text = texts.get("TEST_ACCESS_ACTIVATED_POPUP", "✅ Доступ выдан!") await callback.answer(popup_text, show_alert=True) - await callback.message.answer(success_message) + back_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.get("BACK_TO_MENU", "🏠 В главное меню"), + callback_data="back_to_menu", + ) + ] + ] + ) + await callback.message.answer(success_message, reply_markup=back_keyboard) return discount_percent = int(offer.discount_percent or 0) diff --git a/app/services/promo_offer_service.py b/app/services/promo_offer_service.py index 4dd2c18d..c4427562 100644 --- a/app/services/promo_offer_service.py +++ b/app/services/promo_offer_service.py @@ -34,10 +34,21 @@ class PromoOfferService: return False, None, None, "subscription_missing" payload = offer.extra_data or {} - squad_uuids: Sequence[str] = payload.get("test_squad_uuids") or payload.get("squads") or [] + raw_squads = payload.get("test_squad_uuids") or payload.get("squads") or [] + if isinstance(raw_squads, str): + candidates = [raw_squads] + else: + try: + candidates = list(raw_squads) + except TypeError: + candidates = [] + + squad_uuids: Sequence[str] = [str(item) for item in candidates if item] if not squad_uuids: return False, None, None, "squads_missing" + squad_uuids = list(dict.fromkeys(squad_uuids)) + try: duration_hours = int(payload.get("test_duration_hours") or payload.get("duration_hours") or 24) except (TypeError, ValueError): @@ -50,7 +61,9 @@ class PromoOfferService: expires_at = now + timedelta(hours=duration_hours) connected = set(subscription.connected_squads or []) + original_connected = set(connected) newly_added: List[str] = [] + changes_made = False for squad_uuid in squad_uuids: normalized_uuid = str(squad_uuid) @@ -64,13 +77,16 @@ class PromoOfferService: ) existing_access = existing_result.scalars().first() if existing_access and existing_access.is_active: - existing_access.expires_at = expires_at + if existing_access.expires_at < expires_at: + existing_access.expires_at = expires_at + changes_made = True continue was_already_connected = normalized_uuid in connected if not was_already_connected: connected.add(normalized_uuid) newly_added.append(normalized_uuid) + changes_made = True access_entry = SubscriptionTemporaryAccess( subscription_id=subscription.id, @@ -81,16 +97,33 @@ class PromoOfferService: was_already_connected=was_already_connected, ) db.add(access_entry) + changes_made = True + + connected_changed = connected != original_connected if newly_added: subscription.connected_squads = list(connected) subscription.updated_at = now + changes_made = True - await db.commit() - await db.refresh(subscription) + if connected_changed: + remnawave_user = await self.subscription_service.update_remnawave_user( + db, + subscription, + ) + if remnawave_user is None: + await db.rollback() + await db.refresh(subscription) + logger.error( + "Не удалось синхронизировать временный доступ подписки %s с RemnaWave", + subscription.id, + ) + return False, None, None, "remnawave_sync_failed" - if newly_added: - await self.subscription_service.update_remnawave_user(db, subscription) + await db.refresh(subscription) + elif changes_made: + await db.commit() + await db.refresh(subscription) return True, newly_added, expires_at, "ok" diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 012bf6f8..74f0877a 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -78,31 +78,56 @@ def get_traffic_reset_strategy(): class SubscriptionService: def __init__(self): + self._config_error: Optional[str] = None + self.api: Optional[RemnaWaveAPI] = None + self._last_config_signature: Optional[Tuple[str, ...]] = None + + self._refresh_configuration() + + def _refresh_configuration(self) -> None: auth_params = settings.get_remnawave_auth_params() base_url = (auth_params.get("base_url") or "").strip() api_key = (auth_params.get("api_key") or "").strip() + secret_key = (auth_params.get("secret_key") or "").strip() or None + username = (auth_params.get("username") or "").strip() or None + password = (auth_params.get("password") or "").strip() or None + auth_type = (auth_params.get("auth_type") or "").strip() or None - self._config_error: Optional[str] = None + config_signature = ( + base_url, + api_key, + secret_key or "", + username or "", + password or "", + auth_type or "", + ) + + if config_signature == self._last_config_signature: + return if not base_url: self._config_error = "REMNAWAVE_API_URL не настроен" + self.api = None elif not api_key: self._config_error = "REMNAWAVE_API_KEY не настроен" + self.api = None + else: + self._config_error = None + self.api = RemnaWaveAPI( + base_url=base_url, + api_key=api_key, + secret_key=secret_key, + username=username, + password=password, + ) if self._config_error: logger.warning( "RemnaWave API недоступен: %s. Подписочный сервис будет работать в оффлайн-режиме.", self._config_error ) - self.api = None - else: - self.api = RemnaWaveAPI( - base_url=base_url, - api_key=api_key, - secret_key=auth_params.get("secret_key"), - username=auth_params.get("username"), - password=auth_params.get("password") - ) + + self._last_config_signature = config_signature @property def is_configured(self) -> bool: @@ -113,6 +138,7 @@ class SubscriptionService: return self._config_error def _ensure_configured(self) -> None: + self._refresh_configuration() if not self.api or not self.is_configured: raise RemnaWaveAPIError( self._config_error or "RemnaWave API не настроен" diff --git a/locales/en.json b/locales/en.json index 817b8091..edcb8662 100644 --- a/locales/en.json +++ b/locales/en.json @@ -536,6 +536,7 @@ "DISCOUNT_CLAIM_ERROR": "❌ Failed to activate the discount. Please try again later.", "TEST_ACCESS_NO_SUBSCRIPTION": "❌ You need an active subscription to use this offer.", "TEST_ACCESS_NO_SQUADS": "❌ Unable to determine servers for the test access. Please contact support.", + "TEST_ACCESS_REMNAWAVE_ERROR": "❌ Failed to connect the servers. Please try again later or contact support.", "TEST_ACCESS_UNKNOWN_ERROR": "❌ Failed to activate the offer. Please try again later.", "TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Test servers are connected! Access is active until {expires_at}.", "TEST_ACCESS_ACTIVATED_POPUP": "✅ Access granted!", @@ -584,7 +585,18 @@ "ADMIN_PROMO_OFFER_PROMPT_VALID": "Enter validity (hours):", "ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Enter discount percentage:", "ADMIN_PROMO_OFFER_PROMPT_DURATION": "Enter test access duration (hours):", - "ADMIN_PROMO_OFFER_PROMPT_SQUADS": "List squad UUIDs separated by commas or spaces. Send 'clear' to reset:", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE": "🌍 Select a squad", + "ADMIN_PROMO_OFFER_SELECTED_SQUAD": "Current squad: {name}", + "ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID": "Current squad: {uuid}", + "ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY": "Current squad: not selected", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT": "Pick a server to grant temporary access from the list below.", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE": "Page {page}/{total}", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR": "🗑 Clear", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK": "↩️ Back", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED": "✅ Squad updated", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED": "✅ Squad cleared", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND": "❌ Server not found", + "ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE": "❌ No servers available.", "ADMIN_PROMO_OFFER_SENDING": "Starting broadcast...", "ADMIN_PROMO_OFFER_NO_USERS": "No matching users found.", "ADMIN_PROMO_OFFER_RESULT": "📬 Broadcast finished\nSent: {sent}\nFailed: {failed}", diff --git a/locales/ru.json b/locales/ru.json index be4b4c7b..1ccfdd96 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -536,6 +536,7 @@ "DISCOUNT_CLAIM_ERROR": "❌ Не удалось активировать скидку. Попробуйте позже.", "TEST_ACCESS_NO_SUBSCRIPTION": "❌ Для активации предложения необходима действующая подписка.", "TEST_ACCESS_NO_SQUADS": "❌ Не удалось определить список серверов для теста. Обратитесь к администратору.", + "TEST_ACCESS_REMNAWAVE_ERROR": "❌ Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.", "TEST_ACCESS_UNKNOWN_ERROR": "❌ Не удалось активировать предложение. Попробуйте позже.", "TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.", "TEST_ACCESS_ACTIVATED_POPUP": "✅ Доступ выдан!", @@ -584,7 +585,18 @@ "ADMIN_PROMO_OFFER_PROMPT_VALID": "Укажите срок действия (в часах):", "ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Введите размер скидки в процентах:", "ADMIN_PROMO_OFFER_PROMPT_DURATION": "Введите длительность тестового доступа (в часах):", - "ADMIN_PROMO_OFFER_PROMPT_SQUADS": "Перечислите UUID сквадов через запятую или пробел. Для очистки отправьте 'clear':", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE": "🌍 Выберите сквад", + "ADMIN_PROMO_OFFER_SELECTED_SQUAD": "Текущий сквад: {name}", + "ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID": "Текущий сквад: {uuid}", + "ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY": "Текущий сквад: не выбран", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT": "Выберите сервер для тестового доступа из списка ниже.", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE": "Страница {page}/{total}", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR": "🗑 Очистить", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK": "↩️ Назад", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED": "✅ Сквад обновлён", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED": "✅ Сквад очищен", + "ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND": "❌ Сервер не найден", + "ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE": "❌ Доступные серверы не найдены.", "ADMIN_PROMO_OFFER_SENDING": "Начинаем рассылку...", "ADMIN_PROMO_OFFER_NO_USERS": "Подходящих пользователей не найдено.", "ADMIN_PROMO_OFFER_RESULT": "📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}",