From 19be4348aad0c3e2eb6fb08314359bc70c2c315b Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 22 Sep 2025 21:20:50 +0300 Subject: [PATCH 001/146] Fix ticket system: add noop handlers, pagination, validation, and admin back button routing --- .env.example | 5 +- README.md | 60 +- app/bot.py | 5 +- app/config.py | 14 + app/database/crud/ticket.py | 334 +++++++ app/database/models.py | 114 +++ app/database/universal_migration.py | 50 + app/handlers/admin/main.py | 3 + app/handlers/admin/support_settings.py | 201 ++++ app/handlers/admin/tickets.py | 694 +++++++++++++ app/handlers/common.py | 33 + app/handlers/menu.py | 19 +- app/handlers/support.py | 12 +- app/handlers/tickets.py | 1039 ++++++++++++++++++++ app/keyboards/admin.py | 6 + app/keyboards/inline.py | 300 +++++- app/localization/texts.py | 30 +- app/middlewares/throttling.py | 25 +- app/services/admin_notification_service.py | 29 +- app/services/support_settings_service.py | 112 +++ app/states.py | 12 + app/utils/message_patch.py | 44 +- app/utils/photo_message.py | 44 +- locales/en.json | 439 +++++++++ locales/ru.json | 450 +++++++++ 25 files changed, 4010 insertions(+), 64 deletions(-) create mode 100644 app/database/crud/ticket.py create mode 100644 app/handlers/admin/support_settings.py create mode 100644 app/handlers/admin/tickets.py create mode 100644 app/handlers/tickets.py create mode 100644 app/services/support_settings_service.py create mode 100644 locales/en.json create mode 100644 locales/ru.json diff --git a/.env.example b/.env.example index 7f666c3a..7b388932 100644 --- a/.env.example +++ b/.env.example @@ -8,11 +8,12 @@ ADMIN_IDS= # Ссылка на поддержку: Telegram username (например, @support) или полный URL SUPPORT_USERNAME=@support + # Уведомления администраторов ADMIN_NOTIFICATIONS_ENABLED=true ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ! ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика - +ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов # Обязательная подписка на канал CHANNEL_SUB_ID= # Опционально ID твоего канала (-100) CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал @@ -312,4 +313,4 @@ LOG_FILE=logs/bot.log # ===== РАЗРАБОТКА ===== DEBUG=false WEBHOOK_URL= -WEBHOOK_PATH=/webhook +WEBHOOK_PATH=/webhook \ No newline at end of file diff --git a/README.md b/README.md index 5f829fb8..d3abea85 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![License](https://img.shields.io/badge/License-MIT-green)](LICENSE) [![GitHub Stars](https://img.shields.io/github/stars/Fr1ngg/remnawave-bedolaga-telegram-bot?style=social)](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/stargazers) -[🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [🐳 Docker](#-docker-развертывание) • [💬 Поддержка](#-поддержка-и-сообщество) +[🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [🐳 Docker](#-docker-развертывание) • [💻 Локальная разработка](#-локальная-разработка) • [💬 Поддержка](#-поддержка-и-сообщество) @@ -679,6 +679,64 @@ WEBHOOK_PATH=/webhook --- +## 💻 Локальная разработка + +### 🚀 Быстрый запуск для разработки + +Для локальной разработки используйте специальный Docker Compose файл: + +```bash +# 1. Клонируйте репозиторий +git clone https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot.git +cd remnawave-bedolaga-telegram-bot + +# 2. Настройте окружение +cp env.example .env +# Отредактируйте .env файл с вашими настройками + +# 3. Запустите локально (Windows PowerShell) +.\start-local.ps1 + +# Или вручную +docker-compose -f docker-compose.local.yml up --build +``` + +### 📁 Файлы для локальной разработки + +- `docker-compose.local.yml` - Docker Compose для локальной разработки +- `env.example` - Пример конфигурации +- `start-local.ps1` - Скрипт быстрого запуска (Windows) +- `start-local.sh` - Скрипт быстрого запуска (Linux/macOS) +- `stop-local.ps1` - Скрипт остановки (Windows) +- `README_LOCAL.md` - Подробная документация по локальной разработке + +### 🔧 Особенности локальной разработки + +- **Локальная сборка** - образ собирается из исходного кода +- **Горячая перезагрузка** - изменения в коде автоматически применяются +- **Доступ к БД** - PostgreSQL доступен на localhost:5432 +- **Доступ к Redis** - Redis доступен на localhost:6379 +- **Логи в реальном времени** - все логи выводятся в консоль +- **Отладка** - полный доступ к контейнерам для отладки + +### 📊 Мониторинг разработки + +```bash +# Просмотр логов +docker-compose -f docker-compose.local.yml logs -f bot + +# Проверка статуса +docker-compose -f docker-compose.local.yml ps + +# Health check +curl http://localhost:8081/health + +# Остановка +docker-compose -f docker-compose.local.yml down +``` + +--- + ## 🐳 Docker развертывание ### 📄 docker-compose.yml diff --git a/app/bot.py b/app/bot.py index 51a24080..5c095a7f 100644 --- a/app/bot.py +++ b/app/bot.py @@ -16,7 +16,7 @@ from app.utils.cache import cache from app.handlers import ( start, menu, subscription, balance, promocode, - referral, support, common + referral, support, common, tickets ) from app.handlers.admin import ( main as admin_main, @@ -37,6 +37,7 @@ from app.handlers.admin import ( updates as admin_updates, backup as admin_backup, welcome_text as admin_welcome_text, + tickets as admin_tickets, ) from app.handlers.stars_payments import register_stars_handlers @@ -117,6 +118,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: promocode.register_handlers(dp) referral.register_handlers(dp) support.register_handlers(dp) + tickets.register_handlers(dp) admin_main.register_handlers(dp) admin_users.register_handlers(dp) admin_subscriptions.register_handlers(dp) @@ -135,6 +137,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_updates.register_handlers(dp) admin_backup.register_handlers(dp) admin_welcome_text.register_welcome_text_handlers(dp) + admin_tickets.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/config.py b/app/config.py index e063f19e..4e3bded5 100644 --- a/app/config.py +++ b/app/config.py @@ -13,10 +13,14 @@ class Settings(BaseSettings): BOT_TOKEN: str ADMIN_IDS: str = "" SUPPORT_USERNAME: str = "@support" + SUPPORT_MENU_ENABLED: bool = True + SUPPORT_SYSTEM_MODE: str = "both" # one of: tickets, contact, both + SUPPORT_MENU_ENABLED: bool = True ADMIN_NOTIFICATIONS_ENABLED: bool = False ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None + ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None CHANNEL_SUB_ID: Optional[str] = None CHANNEL_LINK: Optional[str] = None @@ -740,6 +744,16 @@ class Settings(BaseSettings): def get_support_contact_display_html(self) -> str: return html.escape(self.get_support_contact_display()) + + def get_support_system_mode(self) -> str: + mode = (self.SUPPORT_SYSTEM_MODE or "both").strip().lower() + return mode if mode in {"tickets", "contact", "both"} else "both" + + def is_support_tickets_enabled(self) -> bool: + return self.get_support_system_mode() in {"tickets", "both"} + + def is_support_contact_enabled(self) -> bool: + return self.get_support_system_mode() in {"contact", "both"} enabled_packages = [pkg for pkg in packages if pkg["enabled"]] diff --git a/app/database/crud/ticket.py b/app/database/crud/ticket.py new file mode 100644 index 00000000..add26178 --- /dev/null +++ b/app/database/crud/ticket.py @@ -0,0 +1,334 @@ +from typing import List, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc, and_, or_, update, func +from sqlalchemy.orm import selectinload +from datetime import datetime + +from app.database.models import Ticket, TicketMessage, TicketStatus, User + + +class TicketCRUD: + """CRUD операции для работы с тикетами""" + + @staticmethod + async def create_ticket( + db: AsyncSession, + user_id: int, + title: str, + message_text: str, + priority: str = "normal", + *, + media_type: Optional[str] = None, + media_file_id: Optional[str] = None, + media_caption: Optional[str] = None, + ) -> Ticket: + """Создать новый тикет с первым сообщением""" + ticket = Ticket( + user_id=user_id, + title=title, + status=TicketStatus.OPEN.value, + priority=priority + ) + db.add(ticket) + await db.flush() # Получаем ID тикета + + # Создаем первое сообщение + message = TicketMessage( + ticket_id=ticket.id, + user_id=user_id, + message_text=message_text, + is_from_admin=False, + has_media=bool(media_type and media_file_id), + media_type=media_type, + media_file_id=media_file_id, + media_caption=media_caption, + ) + db.add(message) + + await db.commit() + await db.refresh(ticket) + return ticket + + @staticmethod + async def get_ticket_by_id( + db: AsyncSession, + ticket_id: int, + load_messages: bool = True, + load_user: bool = False + ) -> Optional[Ticket]: + """Получить тикет по ID""" + query = select(Ticket).where(Ticket.id == ticket_id) + + if load_user: + query = query.options(selectinload(Ticket.user)) + + if load_messages: + query = query.options(selectinload(Ticket.messages)) + + result = await db.execute(query) + return result.scalar_one_or_none() + + @staticmethod + async def get_user_tickets( + db: AsyncSession, + user_id: int, + status: Optional[str] = None, + limit: int = 20, + offset: int = 0 + ) -> List[Ticket]: + """Получить тикеты пользователя""" + query = select(Ticket).where(Ticket.user_id == user_id) + + if status: + query = query.where(Ticket.status == status) + + query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit) + + result = await db.execute(query) + return result.scalars().all() + + @staticmethod + async def user_has_active_ticket( + db: AsyncSession, + user_id: int + ) -> bool: + """Проверить, есть ли у пользователя активный (не закрытый) тикет""" + query = ( + select(Ticket.id) + .where( + Ticket.user_id == user_id, + Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value]) + ) + .limit(1) + ) + result = await db.execute(query) + return result.scalar_one_or_none() is not None + + @staticmethod + async def is_user_globally_blocked( + db: AsyncSession, + user_id: int + ) -> Optional[datetime]: + """Проверить, заблокирован ли пользователь для создания/ответов по любому тикету. + Возвращает дату окончания блокировки, если активна, или None. + """ + query = select(Ticket).where( + Ticket.user_id == user_id, + or_(Ticket.user_reply_block_permanent == True, Ticket.user_reply_block_until.isnot(None)) + ).order_by(desc(Ticket.updated_at)).limit(10) + result = await db.execute(query) + tickets = result.scalars().all() + if not tickets: + return None + from datetime import datetime + # Если есть вечная блокировка в любом тикете — блок активен без срока + for t in tickets: + if t.user_reply_block_permanent: + return datetime.max + # Иначе ищем максимальный срок блокировки, если он в будущем + future_until = [t.user_reply_block_until for t in tickets if t.user_reply_block_until] + if not future_until: + return None + max_until = max(future_until) + return max_until if max_until > datetime.utcnow() else None + + @staticmethod + async def get_all_tickets( + db: AsyncSession, + status: Optional[str] = None, + priority: Optional[str] = None, + limit: int = 50, + offset: int = 0 + ) -> List[Ticket]: + """Получить все тикеты (для админов)""" + query = select(Ticket).options(selectinload(Ticket.user)) + + conditions = [] + if status: + conditions.append(Ticket.status == status) + if priority: + conditions.append(Ticket.priority == priority) + + if conditions: + query = query.where(and_(*conditions)) + + query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit) + + result = await db.execute(query) + return result.scalars().all() + + @staticmethod + async def get_tickets_by_statuses( + db: AsyncSession, + statuses: List[str], + limit: int = 50, + offset: int = 0 + ) -> List[Ticket]: + query = select(Ticket).options(selectinload(Ticket.user)) + if statuses: + query = query.where(Ticket.status.in_(statuses)) + query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit) + result = await db.execute(query) + return result.scalars().all() + + @staticmethod + async def count_tickets( + db: AsyncSession, + status: Optional[str] = None + ) -> int: + query = select(func.count()).select_from(Ticket) + if status: + query = query.where(Ticket.status == status) + result = await db.execute(query) + return int(result.scalar() or 0) + + @staticmethod + async def count_tickets_by_statuses( + db: AsyncSession, + statuses: List[str] + ) -> int: + query = select(func.count()).select_from(Ticket) + if statuses: + query = query.where(Ticket.status.in_(statuses)) + result = await db.execute(query) + return int(result.scalar() or 0) + + @staticmethod + async def update_ticket_status( + db: AsyncSession, + ticket_id: int, + status: str, + closed_at: Optional[datetime] = None + ) -> bool: + """Обновить статус тикета""" + ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False) + if not ticket: + return False + + ticket.status = status + ticket.updated_at = datetime.utcnow() + + if status == TicketStatus.CLOSED.value and closed_at: + ticket.closed_at = closed_at + + await db.commit() + return True + + @staticmethod + async def set_user_reply_block( + db: AsyncSession, + ticket_id: int, + permanent: bool, + until: Optional[datetime] + ) -> bool: + ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False) + if not ticket: + return False + ticket.user_reply_block_permanent = bool(permanent) + ticket.user_reply_block_until = until + ticket.updated_at = datetime.utcnow() + await db.commit() + return True + + @staticmethod + async def close_ticket( + db: AsyncSession, + ticket_id: int + ) -> bool: + """Закрыть тикет""" + return await TicketCRUD.update_ticket_status( + db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow() + ) + + @staticmethod + async def get_open_tickets_count(db: AsyncSession) -> int: + """Получить количество открытых тикетов""" + query = select(Ticket).where(Ticket.status.in_([ + TicketStatus.OPEN.value, + TicketStatus.ANSWERED.value + ])) + result = await db.execute(query) + return len(result.scalars().all()) + + +class TicketMessageCRUD: + """CRUD операции для работы с сообщениями тикетов""" + + @staticmethod + async def add_message( + db: AsyncSession, + ticket_id: int, + user_id: int, + message_text: str, + is_from_admin: bool = False, + media_type: Optional[str] = None, + media_file_id: Optional[str] = None, + media_caption: Optional[str] = None + ) -> TicketMessage: + """Добавить сообщение в тикет""" + message = TicketMessage( + ticket_id=ticket_id, + user_id=user_id, + message_text=message_text, + is_from_admin=is_from_admin, + has_media=bool(media_type and media_file_id), + media_type=media_type, + media_file_id=media_file_id, + media_caption=media_caption + ) + + db.add(message) + + # Обновляем статус тикета + ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False) + if ticket: + # Если тикет закрыт, запрещаем изменение статуса при сообщении пользователя + if not is_from_admin and ticket.status == TicketStatus.CLOSED.value: + return message + if is_from_admin: + # Админ ответил - тикет отвечен + ticket.status = TicketStatus.ANSWERED.value + else: + # Пользователь ответил - тикет открыт + ticket.status = TicketStatus.OPEN.value + + ticket.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(message) + return message + + @staticmethod + async def get_ticket_messages( + db: AsyncSession, + ticket_id: int, + limit: int = 50, + offset: int = 0 + ) -> List[TicketMessage]: + """Получить сообщения тикета""" + query = ( + select(TicketMessage) + .where(TicketMessage.ticket_id == ticket_id) + .order_by(TicketMessage.created_at) + .offset(offset) + .limit(limit) + ) + + result = await db.execute(query) + return result.scalars().all() + + @staticmethod + async def get_last_message( + db: AsyncSession, + ticket_id: int + ) -> Optional[TicketMessage]: + """Получить последнее сообщение в тикете""" + query = ( + select(TicketMessage) + .where(TicketMessage.ticket_id == ticket_id) + .order_by(desc(TicketMessage.created_at)) + .limit(1) + ) + + result = await db.execute(query) + return result.scalar_one_or_none() diff --git a/app/database/models.py b/app/database/models.py index 28523417..8cfa481a 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -784,3 +784,117 @@ class AdvertisingCampaignRegistration(Base): @property def balance_bonus_rubles(self) -> float: return (self.balance_bonus_kopeks or 0) / 100 + + +class TicketStatus(Enum): + OPEN = "open" + ANSWERED = "answered" + CLOSED = "closed" + PENDING = "pending" + + +class Ticket(Base): + __tablename__ = "tickets" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + title = Column(String(255), nullable=False) + status = Column(String(20), default=TicketStatus.OPEN.value, nullable=False) + priority = Column(String(20), default="normal", nullable=False) # low, normal, high, urgent + # Блокировка ответов пользователя в этом тикете + user_reply_block_permanent = Column(Boolean, default=False, nullable=False) + user_reply_block_until = Column(DateTime, nullable=True) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + closed_at = Column(DateTime, nullable=True) + + # Связи + user = relationship("User", backref="tickets") + messages = relationship("TicketMessage", back_populates="ticket", cascade="all, delete-orphan") + + @property + def is_open(self) -> bool: + return self.status == TicketStatus.OPEN.value + + @property + def is_answered(self) -> bool: + return self.status == TicketStatus.ANSWERED.value + + @property + def is_closed(self) -> bool: + return self.status == TicketStatus.CLOSED.value + + @property + def is_pending(self) -> bool: + return self.status == TicketStatus.PENDING.value + + @property + def is_user_reply_blocked(self) -> bool: + if self.user_reply_block_permanent: + return True + if self.user_reply_block_until: + try: + from datetime import datetime + return self.user_reply_block_until > datetime.utcnow() + except Exception: + return True + return False + + @property + def status_emoji(self) -> str: + status_emojis = { + TicketStatus.OPEN.value: "🔴", + TicketStatus.ANSWERED.value: "🟡", + TicketStatus.CLOSED.value: "🟢", + TicketStatus.PENDING.value: "⏳" + } + return status_emojis.get(self.status, "❓") + + @property + def priority_emoji(self) -> str: + priority_emojis = { + "low": "🟢", + "normal": "🟡", + "high": "🟠", + "urgent": "🔴" + } + return priority_emojis.get(self.priority, "🟡") + + def __repr__(self): + return f"" + + +class TicketMessage(Base): + __tablename__ = "ticket_messages" + + id = Column(Integer, primary_key=True, index=True) + ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + message_text = Column(Text, nullable=False) + is_from_admin = Column(Boolean, default=False, nullable=False) + + # Для медиа файлов + has_media = Column(Boolean, default=False) + media_type = Column(String(20), nullable=True) # photo, video, document, voice, etc. + media_file_id = Column(String(255), nullable=True) + media_caption = Column(Text, nullable=True) + + created_at = Column(DateTime, default=func.now()) + + # Связи + ticket = relationship("Ticket", back_populates="messages") + user = relationship("User") + + @property + def is_user_message(self) -> bool: + return not self.is_from_admin + + @property + def is_admin_message(self) -> bool: + return self.is_from_admin + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index d186298d..2b1136ca 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -772,6 +772,49 @@ async def add_media_fields_to_broadcast_history(): logger.error(f"Ошибка при добавлении полей медиа в broadcast_history: {e}") return False + +async def add_ticket_reply_block_columns(): + try: + col_perm_exists = await check_column_exists('tickets', 'user_reply_block_permanent') + col_until_exists = await check_column_exists('tickets', 'user_reply_block_until') + + if col_perm_exists and col_until_exists: + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if not col_perm_exists: + if db_type == 'sqlite': + alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_permanent BOOLEAN DEFAULT 0 NOT NULL" + elif db_type == 'postgresql': + alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_permanent BOOLEAN DEFAULT FALSE NOT NULL" + elif db_type == 'mysql': + alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_permanent BOOLEAN DEFAULT FALSE NOT NULL" + else: + logger.error(f"Неподдерживаемый тип БД для добавления user_reply_block_permanent: {db_type}") + return False + await conn.execute(text(alter_sql)) + logger.info("✅ Добавлена колонка tickets.user_reply_block_permanent") + + if not col_until_exists: + if db_type == 'sqlite': + alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_until DATETIME NULL" + elif db_type == 'postgresql': + alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_until TIMESTAMP NULL" + elif db_type == 'mysql': + alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_until DATETIME NULL" + else: + logger.error(f"Неподдерживаемый тип БД для добавления user_reply_block_until: {db_type}") + return False + await conn.execute(text(alter_sql)) + logger.info("✅ Добавлена колонка tickets.user_reply_block_until") + + return True + except Exception as e: + logger.error(f"Ошибка добавления колонок блокировок в tickets: {e}") + return False + async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: @@ -1057,6 +1100,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением медиа полей") + logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ БЛОКИРОВКИ В TICKETS ===") + tickets_block_cols_added = await add_ticket_reply_block_columns() + if tickets_block_cols_added: + logger.info("✅ Поля блокировок в tickets готовы") + else: + logger.warning("⚠️ Проблемы с добавлением полей блокировок в tickets") + logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===") promo_groups_ready = await ensure_promo_groups_setup() if promo_groups_ready: diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py index cc129acb..dfce24d7 100644 --- a/app/handlers/admin/main.py +++ b/app/handlers/admin/main.py @@ -14,6 +14,7 @@ from app.keyboards.admin import ( get_admin_system_submenu_keyboard ) from app.localization.texts import get_texts +from app.handlers.admin import support_settings as support_settings_handlers from app.utils.decorators import admin_required, error_handler from app.database.crud.rules import clear_all_rules, get_rules_statistics from app.localization.texts import clear_rules_cache @@ -293,6 +294,8 @@ def register_handlers(dp: Dispatcher): show_system_submenu, F.data == "admin_submenu_system" ) + # Support settings module + support_settings_handlers.register_handlers(dp) dp.message.register( clear_rules_command, diff --git a/app/handlers/admin/support_settings.py b/app/handlers/admin/support_settings.py new file mode 100644 index 00000000..eb93e841 --- /dev/null +++ b/app/handlers/admin/support_settings.py @@ -0,0 +1,201 @@ +import logging +import re +import html +import contextlib +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User +from app.localization.texts import get_texts +from app.utils.decorators import admin_required, error_handler +from app.services.support_settings_service import SupportSettingsService +from app.states import SupportSettingsStates + + +logger = logging.getLogger(__name__) + + +def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup: + texts = get_texts(language) + mode = SupportSettingsService.get_system_mode() + menu_enabled = SupportSettingsService.is_support_menu_enabled() + + rows: list[list[types.InlineKeyboardButton]] = [] + + rows.append([ + types.InlineKeyboardButton( + text=("✅ Пункт 'Техподдержка' в меню" if menu_enabled else "🚫 Пункт 'Техподдержка' в меню"), + callback_data="admin_support_toggle_menu" + ) + ]) + + rows.append([ + types.InlineKeyboardButton(text=("🔘 Тикеты" if mode == "tickets" else "⚪ Тикеты"), callback_data="admin_support_mode_tickets"), + types.InlineKeyboardButton(text=("🔘 Контакт" if mode == "contact" else "⚪ Контакт"), callback_data="admin_support_mode_contact"), + types.InlineKeyboardButton(text=("🔘 Оба" if mode == "both" else "⚪ Оба"), callback_data="admin_support_mode_both"), + ]) + + rows.append([ + types.InlineKeyboardButton(text="📝 Изменить описание", callback_data="admin_support_edit_desc") + ]) + + rows.append([ + types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications") + ]) + + return types.InlineKeyboardMarkup(inline_keyboard=rows) + + +@admin_required +@error_handler +async def show_support_settings( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + texts = get_texts(db_user.language) + desc = SupportSettingsService.get_support_info_text(db_user.language) + await callback.message.edit_text( + "🛟 Настройки поддержки\n\n" + + "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:\n\n" + + desc, + reply_markup=_get_support_settings_keyboard(db_user.language), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_support_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + current = SupportSettingsService.is_support_menu_enabled() + SupportSettingsService.set_support_menu_enabled(not current) + await show_support_settings(callback, db_user, db) + + +@admin_required +@error_handler +async def set_mode_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + SupportSettingsService.set_system_mode("tickets") + await show_support_settings(callback, db_user, db) + + +@admin_required +@error_handler +async def set_mode_contact(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + SupportSettingsService.set_system_mode("contact") + await show_support_settings(callback, db_user, db) + + +@admin_required +@error_handler +async def set_mode_both(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + SupportSettingsService.set_system_mode("both") + await show_support_settings(callback, db_user, db) + + +@admin_required +@error_handler +async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + texts = get_texts(db_user.language) + current_desc_html = SupportSettingsService.get_support_info_text(db_user.language) + # plain text for display-only code block + current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html) + + kb_rows: list[list[types.InlineKeyboardButton]] = [] + kb_rows.append([ + types.InlineKeyboardButton(text="📨 Прислать текст", callback_data="admin_support_send_desc") + ]) + # Подготовим блок контакта (отдельным инлайном) + from app.config import settings + support_contact_display = settings.get_support_contact_display() + kb_rows.append([ + types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings") + ]) + + text_parts = [ + "📝 Редактирование описания поддержки", + "", + "Текущее описание:", + "", + f"{html.escape(current_desc_plain)}", + ] + if support_contact_display: + text_parts += [ + "", + "Контакт для режима \u00abКонтакт\u00bb", + f"{html.escape(support_contact_display)}", + "", + "Добавьте в описание при необходимости.", + ] + await callback.message.edit_text( + "\n".join(text_parts), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=kb_rows), + parse_mode="HTML" + ) + await state.set_state(SupportSettingsStates.waiting_for_desc) + await callback.answer() + + +@admin_required +@error_handler +async def handle_new_desc(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): + new_text = message.html_text or message.text + SupportSettingsService.set_support_info_text(db_user.language, new_text) + await state.clear() + markup = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + await message.answer("✅ Описание обновлено.", reply_markup=markup) + + +@admin_required +@error_handler +async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + # send plain text for easy copying + current_desc_html = SupportSettingsService.get_support_info_text(db_user.language) + current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html) + # attach delete button to the sent message + markup = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + if len(current_desc_plain) <= 4000: + await callback.message.answer(current_desc_plain, reply_markup=markup) + else: + # split long messages (attach delete only to the last chunk) + chunk = 0 + while chunk < len(current_desc_plain): + next_chunk = current_desc_plain[chunk:chunk+4000] + is_last = (chunk + 4000) >= len(current_desc_plain) + await callback.message.answer(next_chunk, reply_markup=(markup if is_last else None)) + chunk += 4000 + await callback.answer("Текст отправлен ниже") + + +@admin_required +@error_handler +async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + try: + await callback.message.delete() + finally: + with contextlib.suppress(Exception): + await callback.answer("Сообщение удалено") + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_support_settings, F.data == "admin_support_settings") + dp.callback_query.register(toggle_support_menu, F.data == "admin_support_toggle_menu") + dp.callback_query.register(set_mode_tickets, F.data == "admin_support_mode_tickets") + dp.callback_query.register(set_mode_contact, F.data == "admin_support_mode_contact") + dp.callback_query.register(set_mode_both, F.data == "admin_support_mode_both") + dp.callback_query.register(start_edit_desc, F.data == "admin_support_edit_desc") + dp.callback_query.register(send_desc_copy, F.data == "admin_support_send_desc") + dp.callback_query.register(delete_sent_message, F.data == "admin_support_delete_msg") + dp.message.register(handle_new_desc, SupportSettingsStates.waiting_for_desc) + + diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py new file mode 100644 index 00000000..c943a5db --- /dev/null +++ b/app/handlers/admin/tickets.py @@ -0,0 +1,694 @@ +import logging +from typing import List, Dict, Any +from aiogram import Dispatcher, types, F, Bot +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc, and_ +from datetime import datetime, timedelta +import time + +from app.database.models import User, Ticket, TicketStatus +from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD +from app.states import TicketStates, AdminTicketStates +from app.keyboards.inline import ( + get_admin_tickets_keyboard, + get_admin_ticket_view_keyboard, + get_admin_ticket_reply_cancel_keyboard +) +from app.localization.texts import get_texts +from app.utils.pagination import paginate_list, get_pagination_info +from app.services.admin_notification_service import AdminNotificationService +from app.config import settings +from app.utils.cache import RateLimitCache + +logger = logging.getLogger(__name__) + + + + + +async def show_admin_tickets( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Показать все тикеты для админов""" + texts = get_texts(db_user.language) + + # Определяем текущую страницу и scope + current_page = 1 + scope = "open" + data_str = callback.data + if data_str == "admin_tickets_scope_open": + scope = "open" + elif data_str == "admin_tickets_scope_closed": + scope = "closed" + elif data_str.startswith("admin_tickets_page_"): + try: + parts = data_str.split("_") + # format: admin_tickets_page_{scope}_{page} + if len(parts) >= 5: + scope = parts[3] + current_page = int(parts[4]) + else: + current_page = int(data_str.replace("admin_tickets_page_", "")) + except ValueError: + current_page = 1 + statuses = [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value] if scope == "open" else [TicketStatus.CLOSED.value] + page_size = 10 + # total count for proper pagination + total_count = await TicketCRUD.count_tickets_by_statuses(db, statuses) + total_pages = max(1, (total_count + page_size - 1) // page_size) if total_count > 0 else 1 + if current_page > total_pages: + current_page = total_pages + offset = (current_page - 1) * page_size + tickets = await TicketCRUD.get_tickets_by_statuses(db, statuses=statuses, limit=page_size, offset=offset) + + # Даже если тикетов нет, показываем переключатели разделов + + # Формируем данные для клавиатуры + ticket_data = [] + for ticket in tickets: + user_name = ticket.user.full_name if ticket.user else "Unknown" + ticket_data.append({ + 'id': ticket.id, + 'title': ticket.title, + 'status_emoji': ticket.status_emoji, + 'priority_emoji': ticket.priority_emoji, + 'user_name': user_name, + 'is_closed': ticket.is_closed, + 'locked_emoji': ("🔒" if ticket.is_user_reply_blocked else "") + }) + + # Итоговые страницы уже посчитаны выше + await callback.message.edit_text( + texts.t("ADMIN_TICKETS_TITLE", "🎫 Все тикеты поддержки:"), + reply_markup=get_admin_tickets_keyboard(ticket_data, current_page=current_page, total_pages=total_pages, language=db_user.language, scope=scope) + ) + await callback.answer() + + +async def view_admin_ticket( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + """Показать детали тикета для админа""" + ticket_id = int(callback.data.replace("admin_view_ticket_", "")) + + 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( + 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) + + user_name = ticket.user.full_name if ticket.user else "Unknown" + + ticket_text = f"🎫 Тикет #{ticket.id}\n\n" + ticket_text += f"👤 Пользователь: {user_name}\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" + + if ticket.is_user_reply_blocked: + if ticket.user_reply_block_permanent: + ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n" + elif ticket.user_reply_block_until: + ticket_text += f"⏳ Блок до: {ticket.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n" + + if ticket.messages: + ticket_text += 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" + if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo": + ticket_text += "📎 Вложение: фото\n\n" + + # Добавим кнопку "Вложения", если есть фото + 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, + ticket.is_closed, + db_user.language + ) + if has_photos: + try: + keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{ticket_id}")]) + except Exception: + pass + + # Сначала пробуем отредактировать; если не вышло — удалим и отправим новое + try: + await callback.message.edit_text( + ticket_text, + reply_markup=keyboard, + ) + except Exception: + try: + await callback.message.delete() + except Exception: + pass + await callback.message.answer( + ticket_text, + reply_markup=keyboard, + ) + # сохраняем id для дальнейших действий (ответ/статусы) + await state.update_data(ticket_id=ticket_id) + await callback.answer() + + +async def reply_to_admin_ticket( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User +): + """Начать ответ на тикет от админа""" + ticket_id = int(callback.data.replace("admin_reply_ticket_", "")) + + await state.update_data(ticket_id=ticket_id, reply_mode=True) + texts = get_texts(db_user.language) + await callback.message.edit_text( + texts.t("ADMIN_TICKET_REPLY_INPUT", "Введите ответ от поддержки:"), + reply_markup=get_admin_ticket_reply_cancel_keyboard(db_user.language) + ) + + await state.set_state(AdminTicketStates.waiting_for_reply) + await callback.answer() + + +async def handle_admin_ticket_reply( + message: types.Message, + state: FSMContext, + db_user: User, + db: AsyncSession +): + # Проверяем, что пользователь в правильном состоянии + current_state = await state.get_state() + if current_state != AdminTicketStates.waiting_for_reply: + return + + # Анти-спам: одно сообщение за короткое окно по конкретному тикету + try: + data_rl = await state.get_data() + rl_ticket_id = data_rl.get("ticket_id") or "admin_reply" + limited = await RateLimitCache.is_rate_limited(db_user.id, f"admin_ticket_reply_{rl_ticket_id}", limit=1, window=2) + if limited: + return + except Exception: + pass + try: + data_rl = await state.get_data() + last_ts = data_rl.get("admin_rl_ts_reply") + now_ts = time.time() + if last_ts and (now_ts - float(last_ts)) < 2: + return + await state.update_data(admin_rl_ts_reply=now_ts) + except Exception: + pass + + """Обработать ответ админа на тикет""" + # Поддержка фото вложений в ответе админа + reply_text = (message.text or message.caption or "").strip() + 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) < 1 and not media_file_id: + 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") + try: + ticket_id = int(ticket_id) if ticket_id is not None else None + except (TypeError, ValueError): + ticket_id = None + + if not ticket_id: + texts = get_texts(db_user.language) + await message.answer( + texts.t("TICKET_REPLY_ERROR", "Ошибка: не найден ID тикета.") + ) + await state.clear() + return + + try: + # Если это режим ввода длительности блокировки + if not data.get("reply_mode"): + try: + minutes = int(reply_text) + minutes = max(1, min(60*24*365, minutes)) + except ValueError: + await message.answer("❌ Введите целое число минут") + return + until = datetime.utcnow() + timedelta(minutes=minutes) + ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=until) + if ok: + await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") + else: + await message.answer("❌ Ошибка блокировки") + await state.clear() + return + + # Обычный режим ответа админа + ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False) + if not ticket: + texts = get_texts(db_user.language) + await message.answer( + texts.t("TICKET_NOT_FOUND", "Тикет не найден.") + ) + await state.clear() + return + + # Добавляем сообщение от админа (внутри add_message статус станет ANSWERED) + await TicketMessageCRUD.add_message( + db, + ticket_id, + db_user.id, + reply_text, + is_from_admin=True, + 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("ADMIN_TICKET_REPLY_SENT", "✅ Ответ отправлен!"), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), + callback_data=f"admin_view_ticket_{ticket_id}" + )], + [types.InlineKeyboardButton( + text=texts.t("BACK_TO_TICKETS", "⬅️ К тикетам"), + callback_data="admin_tickets" + )] + ]) + ) + + await state.clear() + + # Уведомляем пользователя о новом ответе + await notify_user_about_ticket_reply(message.bot, ticket, reply_text, db) + # Админ-уведомления о ответе в тикет отключены по требованию + + except Exception as e: + logger.error(f"Error adding admin ticket reply: {e}") + texts = get_texts(db_user.language) + await message.answer( + texts.t("TICKET_REPLY_ERROR", "❌ Произошла ошибка при отправке ответа. Попробуйте позже.") + ) + + +async def mark_ticket_as_answered( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Отметить тикет как отвеченный""" + ticket_id = int(callback.data.replace("admin_mark_answered_", "")) + + try: + success = await TicketCRUD.update_ticket_status( + db, ticket_id, TicketStatus.ANSWERED.value + ) + + if success: + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TICKET_MARKED_ANSWERED", "✅ Тикет отмечен как отвеченный."), + show_alert=True + ) + + # Обновляем сообщение + await view_admin_ticket(callback, db_user, db) + else: + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."), + show_alert=True + ) + + except Exception as e: + logger.error(f"Error marking ticket as answered: {e}") + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."), + show_alert=True + ) + + +async def close_admin_ticket( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Закрыть тикет админом""" + ticket_id = int(callback.data.replace("admin_close_ticket_", "")) + + try: + 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_admin_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(f"Error closing admin ticket: {e}") + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TICKET_CLOSE_ERROR", "❌ Ошибка при закрытии тикета."), + show_alert=True + ) + + +async def cancel_admin_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="admin_tickets" + )] + ]) + ) + await callback.answer() + + +async def block_user_in_ticket( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + ticket_id = int(callback.data.replace("admin_block_user_ticket_", "")) + texts = get_texts(db_user.language) + await callback.message.edit_text( + texts.t("ENTER_BLOCK_MINUTES", "Введите количество минут для блокировки пользователя (например, 15):"), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text=texts.t("CANCEL_REPLY", "❌ Отменить ответ"), + callback_data="cancel_admin_ticket_reply" + )] + ]) + ) + await state.update_data(ticket_id=ticket_id) + await state.set_state(AdminTicketStates.waiting_for_block_duration) + await callback.answer() + + +async def handle_admin_block_duration_input( + message: types.Message, + state: FSMContext, + db_user: User, + db: AsyncSession +): + # Проверяем состояние + current_state = await state.get_state() + if current_state != AdminTicketStates.waiting_for_block_duration: + return + + reply_text = message.text.strip() + if len(reply_text) < 1: + await message.answer("❌ Введите целое число минут") + return + + data = await state.get_data() + ticket_id = data.get("ticket_id") + try: + minutes = int(reply_text) + minutes = max(1, min(60*24*365, minutes)) # максимум 1 год + except ValueError: + await message.answer("❌ Введите целое число минут") + return + + 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: + texts = get_texts(db_user.language) + await message.answer(texts.t("TICKET_NOT_FOUND", "Тикет не найден.")) + await state.clear() + return + + until = datetime.utcnow() + timedelta(minutes=minutes) + ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=until) + if ok: + await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") + else: + await message.answer("❌ Ошибка блокировки") + await state.clear() + await message.answer( + "✅ Блокировка установлена. Откройте тикет заново для обновления состояния.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[[types.InlineKeyboardButton(text="👁️ Посмотреть тикет", callback_data=f"admin_view_ticket_{ticket_id}")]]) + ) + except Exception as e: + logger.error(f"Error setting block duration: {e}") + texts = get_texts(db_user.language) + await message.answer(texts.t("TICKET_REPLY_ERROR", "❌ Произошла ошибка. Попробуйте позже.")) + + + + + + + +async def unblock_user_in_ticket( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", "")) + ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=None) + if ok: + await callback.answer("✅ Блок снят") + await view_admin_ticket(callback, db_user, db, FSMContext(callback.bot, callback.from_user.id)) + else: + await callback.answer("❌ Ошибка", show_alert=True) + + +async def block_user_permanently( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + ticket_id = int(callback.data.replace("admin_block_user_perm_ticket_", "")) + ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=True, until=None) + if ok: + await callback.answer("✅ Пользователь заблокирован навсегда") + await view_admin_ticket(callback, db_user, db, FSMContext(callback.bot, callback.from_user.id)) + else: + await callback.answer("❌ Ошибка", show_alert=True) + + +async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: str, db: AsyncSession): + """Уведомить пользователя о новом ответе в тикете""" + try: + from app.localization.texts import get_texts + + # Получаем тикет с пользователем + ticket_with_user = await TicketCRUD.get_ticket_by_id(db, ticket.id, load_user=True) + if not ticket_with_user or not ticket_with_user.user: + logger.error(f"User not found for ticket #{ticket.id}") + return + + texts = get_texts(ticket_with_user.user.language) + + # Формируем уведомление + base_text = texts.t( + "TICKET_REPLY_NOTIFICATION", + "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:" + ).format( + ticket_id=ticket.id, + reply_preview=reply_text[:100] + "..." if len(reply_text) > 100 else reply_text + ) + # Если было фото в последнем ответе админа — отправим как фото + last_message = await TicketMessageCRUD.get_last_message(db, ticket.id) + if last_message and last_message.has_media and last_message.media_type == "photo" and last_message.is_from_admin: + caption = base_text + try: + await bot.send_photo( + chat_id=ticket_with_user.user.telegram_id, + photo=last_message.media_file_id, + caption=caption, + 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("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")] + ]) + ) + return + except Exception as e: + logger.error(f"Не удалось отправить фото-уведомление: {e}") + # Фоллбек: текстовое уведомление + await bot.send_message( + chat_id=ticket_with_user.user.telegram_id, + text=base_text, + 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("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")] + ]) + ) + + logger.info(f"Ticket #{ticket.id} reply notification sent to user {ticket_with_user.user.telegram_id}") + + except Exception as e: + logger.error(f"Error notifying user about ticket reply: {e}") + + +def register_handlers(dp: Dispatcher): + """Регистрация админских обработчиков тикетов""" + + # Просмотр тикетов + dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets") + dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_open") + dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_closed") + + dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_")) + + # Ответы на тикеты + dp.callback_query.register( + reply_to_admin_ticket, + F.data.startswith("admin_reply_ticket_") + ) + + dp.message.register(handle_admin_ticket_reply, AdminTicketStates.waiting_for_reply) + dp.message.register(handle_admin_block_duration_input, AdminTicketStates.waiting_for_block_duration) + + # Управление статусами: явная кнопка больше не используется (статус меняется автоматически) + + dp.callback_query.register( + close_admin_ticket, + F.data.startswith("admin_close_ticket_") + ) + dp.callback_query.register(block_user_in_ticket, F.data.startswith("admin_block_user_ticket_")) + dp.callback_query.register(unblock_user_in_ticket, F.data.startswith("admin_unblock_user_ticket_")) + dp.callback_query.register(block_user_permanently, F.data.startswith("admin_block_user_perm_ticket_")) + + # Отмена операций + dp.callback_query.register( + cancel_admin_ticket_reply, + F.data == "cancel_admin_ticket_reply" + ) + + # Пагинация админских тикетов + dp.callback_query.register(show_admin_tickets, F.data.startswith("admin_tickets_page_")) + + # Управление компоновкой ответа — (отключено) + + # Вложения в тикете (админ) + async def send_admin_ticket_attachments( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession + ): + texts = get_texts(db_user.language) + try: + ticket_id = int(callback.data.replace("admin_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: + 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 + 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"admin_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: + await callback.answer(texts.t("ATTACHMENTS_SENT", "Вложения отправлены.")) + else: + await callback.answer(texts.t("ATTACHMENTS_SENT", "Вложения отправлены.")) + + dp.callback_query.register(send_admin_ticket_attachments, F.data.startswith("admin_ticket_attachments_")) + + async def admin_delete_message( + callback: types.CallbackQuery + ): + try: + msg_id = int(callback.data.replace("admin_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("✅") + + dp.callback_query.register(admin_delete_message, F.data.startswith("admin_delete_message_")) + diff --git a/app/handlers/common.py b/app/handlers/common.py index 98df8288..05ecb178 100644 --- a/app/handlers/common.py +++ b/app/handlers/common.py @@ -28,6 +28,26 @@ async def handle_unknown_callback( logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}") +async def handle_noop( + callback: types.CallbackQuery, + db_user: User +): + try: + await callback.answer() + except Exception: + pass + + +async def handle_current_page( + callback: types.CallbackQuery, + db_user: User +): + try: + await callback.answer() + except Exception: + pass + + async def handle_cancel( callback: types.CallbackQuery, state: FSMContext, @@ -83,9 +103,22 @@ def register_handlers(dp: Dispatcher): show_rules, F.data == "menu_rules" ) + + # No-op utility handlers used in many keyboards + dp.callback_query.register( + handle_noop, + F.data == "noop" + ) + dp.callback_query.register( + handle_current_page, + F.data == "current_page" + ) dp.callback_query.register( handle_cancel, F.data.in_(["cancel", "subscription_cancel"]) ) + + # Самый последний: ловим любые неизвестные текстовые сообщения + dp.message.register(handle_unknown_message) \ No newline at end of file diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 7914379e..9822a144 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -15,6 +15,7 @@ from app.services.subscription_checkout_service import ( has_subscription_checkout_draft, should_offer_checkout_resume, ) +from app.utils.photo_message import edit_or_answer_photo logger = logging.getLogger(__name__) @@ -41,9 +42,10 @@ async def show_main_menu( draft_exists = await has_subscription_checkout_draft(db_user.id) show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) - await callback.message.edit_text( - menu_text, - reply_markup=get_main_menu_keyboard( + await edit_or_answer_photo( + callback=callback, + caption=menu_text, + keyboard=get_main_menu_keyboard( language=db_user.language, is_admin=settings.is_admin(db_user.telegram_id), has_had_paid_subscription=db_user.has_had_paid_subscription, @@ -53,7 +55,7 @@ async def show_main_menu( subscription=db_user.subscription, show_resume_checkout=show_resume_checkout, ), - parse_mode="HTML" + parse_mode="HTML", ) await callback.answer() @@ -112,9 +114,10 @@ async def handle_back_to_menu( draft_exists = await has_subscription_checkout_draft(db_user.id) show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) - await callback.message.edit_text( - menu_text, - reply_markup=get_main_menu_keyboard( + await edit_or_answer_photo( + callback=callback, + caption=menu_text, + keyboard=get_main_menu_keyboard( language=db_user.language, is_admin=settings.is_admin(db_user.telegram_id), has_had_paid_subscription=db_user.has_had_paid_subscription, @@ -124,7 +127,7 @@ async def handle_back_to_menu( subscription=db_user.subscription, show_resume_checkout=show_resume_checkout, ), - parse_mode="HTML" + parse_mode="HTML", ) await callback.answer() diff --git a/app/handlers/support.py b/app/handlers/support.py index e2f0e130..bff90940 100644 --- a/app/handlers/support.py +++ b/app/handlers/support.py @@ -5,7 +5,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User from app.keyboards.inline import get_support_keyboard +from app.services.support_settings_service import SupportSettingsService from app.localization.texts import get_texts +from app.utils.photo_message import edit_or_answer_photo logger = logging.getLogger(__name__) @@ -16,10 +18,12 @@ async def show_support_info( ): texts = get_texts(db_user.language) - - await callback.message.edit_text( - texts.SUPPORT_INFO, - reply_markup=get_support_keyboard(db_user.language) + support_info = SupportSettingsService.get_support_info_text(db_user.language) + await edit_or_answer_photo( + callback=callback, + caption=support_info, + keyboard=get_support_keyboard(db_user.language), + parse_mode="HTML", ) await callback.answer() diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py new file mode 100644 index 00000000..2afc72e0 --- /dev/null +++ b/app/handlers/tickets.py @@ -0,0 +1,1039 @@ +import logging +from typing import List, Dict, Any +import asyncio +import time +from aiogram import Dispatcher, types, F, Bot +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import StatesGroup, State +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User, Ticket, TicketStatus +from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD +from app.keyboards.inline import ( + get_ticket_cancel_keyboard, + get_my_tickets_keyboard, + get_ticket_view_keyboard, + get_ticket_reply_cancel_keyboard, + get_admin_tickets_keyboard, + get_admin_ticket_view_keyboard, + get_admin_ticket_reply_cancel_keyboard +) +from app.localization.texts import get_texts +from app.config import settings +from app.services.admin_notification_service import AdminNotificationService +from app.utils.pagination import paginate_list, get_pagination_info +from app.utils.photo_message import edit_or_answer_photo +from app.utils.cache import RateLimitCache, cache, cache_key + +logger = logging.getLogger(__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 + + await callback.message.edit_text( + texts.t("TICKET_TITLE_INPUT", "Введите заголовок тикета:"), + reply_markup=get_ticket_cancel_keyboard(db_user.language) + ) + # Запоминаем исходное сообщение бота, чтобы далее редактировать его, а не слать новые + await state.update_data(prompt_chat_id=callback.message.chat.id, prompt_message_id=callback.message.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 + + """Обработать ввод заголовка тикета""" + 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) + if prompt_chat_id and prompt_message_id: + text_val = texts.t("TICKET_TITLE_TOO_SHORT", "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:") + if settings.ENABLE_LOGO_MODE: + await message.bot.edit_message_caption( + chat_id=prompt_chat_id, + message_id=prompt_message_id, + caption=text_val, + reply_markup=get_ticket_cancel_keyboard(db_user.language), + parse_mode=None, + ) + else: + await message.bot.edit_message_text( + chat_id=prompt_chat_id, + message_id=prompt_message_id, + text=text_val, + reply_markup=get_ticket_cancel_keyboard(db_user.language), + ) + else: + await message.answer( + texts.t("TICKET_TITLE_TOO_SHORT", "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:") + ) + return + + if len(title) > 255: + texts = get_texts(db_user.language) + if prompt_chat_id and prompt_message_id: + text_val = texts.t("TICKET_TITLE_TOO_LONG", "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:") + if settings.ENABLE_LOGO_MODE: + await message.bot.edit_message_caption( + chat_id=prompt_chat_id, + message_id=prompt_message_id, + caption=text_val, + reply_markup=get_ticket_cancel_keyboard(db_user.language), + parse_mode=None, + ) + else: + await message.bot.edit_message_text( + chat_id=prompt_chat_id, + message_id=prompt_message_id, + text=text_val, + reply_markup=get_ticket_cancel_keyboard(db_user.language), + ) + else: + await message.answer( + texts.t("TICKET_TITLE_TOO_LONG", "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:") + ) + 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) + + if prompt_chat_id and prompt_message_id: + text_val = texts.t("TICKET_MESSAGE_INPUT", "Опишите проблему (до 500 символов) или отправьте фото с подписью:") + if settings.ENABLE_LOGO_MODE: + await message.bot.edit_message_caption( + chat_id=prompt_chat_id, + message_id=prompt_message_id, + caption=text_val, + reply_markup=get_ticket_cancel_keyboard(db_user.language), + parse_mode=None, + ) + else: + await message.bot.edit_message_text( + chat_id=prompt_chat_id, + message_id=prompt_message_id, + text=text_val, + reply_markup=get_ticket_cancel_keyboard(db_user.language), + ) + else: + await message.answer( + texts.t("TICKET_MESSAGE_INPUT", "Опишите проблему (до 500 символов) или отправьте фото с подписью:"), + reply_markup=get_ticket_cancel_keyboard(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: + if settings.ENABLE_LOGO_MODE: + await message.bot.edit_message_caption(chat_id=prompt_chat_id, message_id=prompt_message_id, caption=text_msg, parse_mode=None) + else: + await message.bot.edit_message_text(chat_id=prompt_chat_id, message_id=prompt_message_id, text=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", "Сообщение слишком короткое. Опишите проблему подробнее или отправьте фото:") + if prompt_chat_id and prompt_message_id: + if settings.ENABLE_LOGO_MODE: + await message.bot.edit_message_caption(chat_id=prompt_chat_id, message_id=prompt_message_id, caption=err_text, reply_markup=get_ticket_cancel_keyboard(db_user.language), parse_mode=None) + else: + await message.bot.edit_message_text(chat_id=prompt_chat_id, message_id=prompt_message_id, text=err_text, reply_markup=get_ticket_cancel_keyboard(db_user.language)) + else: + await message.answer(err_text) + 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 = title if len(title) <= 200 else (title[:197] + "...") + creation_text = ( + f"✅ Тикет #{ticket.id} создан\n\n" + f"📝 Заголовок: {safe_title}\n" + f"📊 Статус: {ticket.status_emoji} " + f"{texts.t('TICKET_STATUS_OPEN','Открыт')}\n" + f"📅 Создан: {ticket.created_at.strftime('%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: + if settings.ENABLE_LOGO_MODE: + await message.bot.edit_message_caption( + chat_id=prompt_chat_id, + message_id=prompt_message_id, + caption=creation_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + else: + 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", + ) + 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(f"Error creating ticket: {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 + + # Получаем тикеты пользователя (открытые/закрытые отдельно) + all_tickets = await TicketCRUD.get_user_tickets(db, db_user.id, limit=100) + open_tickets = [t for t in all_tickets if t.status != TicketStatus.CLOSED.value] + closed_tickets = [t for t in all_tickets if t.status == TicketStatus.CLOSED.value] + + if not open_tickets and not closed_tickets: + 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 + + # Открытые с пагинацией + open_data = [] + for t in open_tickets: + if t.status != TicketStatus.CLOSED.value: + open_data.append({'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji}) + per_page = 10 + pag = get_pagination_info(total_count=len(open_data), page=current_page, per_page=per_page) + # Корректируем текущую страницу в допустимые границы + current_page = max(1, min(current_page, pag["total_pages"])) + start_index = (current_page - 1) * per_page + end_index = start_index + per_page + page_items = open_data[start_index:end_index] + keyboard = get_my_tickets_keyboard(page_items, current_page=current_page, total_pages=pag["total_pages"], language=db_user.language) + # Добавим кнопку перехода к закрытым + keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("VIEW_CLOSED_TICKETS", "🟢 Закрытые тикеты"), callback_data="my_tickets_closed")]) + # Покажем список тикетов c логотипом, если режим включен + if settings.ENABLE_LOGO_MODE and callback.message.photo: + from app.utils.photo_message import edit_or_answer_photo + await edit_or_answer_photo( + callback=callback, + caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), + keyboard=keyboard, + parse_mode="HTML", + ) + else: + await callback.message.edit_text(texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), reply_markup=keyboard) + await callback.answer() + + +async def show_my_tickets_closed( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + texts = get_texts(db_user.language) + # Пагинация (при необходимости можно добавить аналогично open) + tickets = await TicketCRUD.get_user_tickets(db, db_user.id, status=TicketStatus.CLOSED.value, limit=10) + if not tickets: + 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 + data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in tickets] + kb = get_my_tickets_keyboard(data, current_page=1, language=db_user.language) + kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("BACK_TO_OPEN_TICKETS", "🔴 Открытые тикеты"), callback_data="my_tickets")]) + if settings.ENABLE_LOGO_MODE and callback.message.photo: + from app.utils.photo_message import edit_or_answer_photo + await edit_or_answer_photo( + callback=callback, + caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), + keyboard=kb, + parse_mode="HTML", + ) + else: + await callback.message.edit_text(texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), reply_markup=kb) + await callback.answer() + + +def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int = 3500) -> list[str]: + pages: list[str] = [] + current = header + for block in message_blocks: + if len(current) + len(block) > max_len: + pages.append(current) + current = header + block + else: + current += block + if current.strip(): + pages.append(current) + return pages if pages else [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"📝 Заголовок: {ticket.title}\n" + f"📊 Статус: {ticket.status_emoji} {status_text}\n" + f"📅 Создан: {ticket.created_at.strftime('%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} ({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": + block += "📎 Вложение: фото\n\n" + message_blocks.append(block) + pages = _split_text_into_pages(header, message_blocks, max_len=3500) + total_pages = len(pages) + if page > total_pages: + 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 _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) + + await callback.message.edit_text( + 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", "✅ Тикет закрыт.") + ) + 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", "❌ Тикет закрыт, ответить невозможно.") + ) + 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() + + except Exception as e: + logger.error(f"Error adding ticket reply: {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(f"Error closing ticket: {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) + + 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(f"Admin notifications disabled. Ticket #{ticket.id} created by user {ticket.user_id}") + return + + # Получаем язык пользователя для локализации заголовков в уведомлении + # и формируем удобный текст уведомления для админов + user_texts = get_texts(settings.DEFAULT_LANGUAGE) + title = (ticket.title or '').strip() + if len(title) > 60: + title = title[:57] + "..." + + notification_text = ( + f"🎫 НОВЫЙ ТИКЕТ\n\n" + f"🆔 ID: {ticket.id}\n" + f"👤 User ID: {ticket.user_id}\n" + f"📝 Заголовок: {title or '—'}\n" + f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n" + ) + + # Клавиатура с быстрыми действиями для админов в топике + # Отправляем через общий сервис админ-уведомлений (поддерживает топики) + # bot доступен из Dispatcher в middlewares; безопаснее взять из уже используемого контекста + # Здесь используем lazy импорт из maintenance_service, где хранится бот + 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) + except Exception as e: + logger.error(f"Error notifying admins about new ticket: {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( + 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_") + ) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index bc6f3334..c7a3a480 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -61,6 +61,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey [ InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages") ], + [ + InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets") + ], + [ + InlineKeyboardButton(text="🛟 Настройки поддержки", callback_data="admin_support_settings") + ], [ InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"), InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel") diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 52afee68..78045fc0 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -157,12 +157,20 @@ def get_main_menu_keyboard( [ InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"), InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals") - ], - [ - InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support"), - InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules") ] ]) + + # Support button is configurable (runtime via service) + try: + from app.services.support_settings_service import SupportSettingsService + support_enabled = SupportSettingsService.is_support_menu_enabled() + except Exception: + support_enabled = settings.SUPPORT_MENU_ENABLED + support_row = [] + if support_enabled: + support_row.append(InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")) + support_row.append(InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules")) + keyboard.append(support_row) if settings.DEBUG: print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}") @@ -718,17 +726,38 @@ def get_referral_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMar def get_support_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ + try: + from app.services.support_settings_service import SupportSettingsService + tickets_enabled = SupportSettingsService.is_tickets_enabled() + contact_enabled = SupportSettingsService.is_contact_enabled() + except Exception: + tickets_enabled = True + contact_enabled = True + rows: list[list[InlineKeyboardButton]] = [] + # Tickets + if tickets_enabled: + rows.append([ InlineKeyboardButton( - text=texts.CONTACT_SUPPORT, + text=texts.t("CREATE_TICKET_BUTTON", "🎫 Создать тикет"), + callback_data="create_ticket" + ) + ]) + rows.append([ + InlineKeyboardButton( + text=texts.t("MY_TICKETS_BUTTON", "📋 Мои тикеты"), + callback_data="my_tickets" + ) + ]) + # Direct contact + if contact_enabled and settings.get_support_contact_url(): + rows.append([ + InlineKeyboardButton( + text=texts.t("CONTACT_SUPPORT_BUTTON", "💬 Связаться с поддержкой"), url=settings.get_support_contact_url() or "https://t.me/" ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") - ] - ]) + ]) + rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]) + return InlineKeyboardMarkup(inline_keyboard=rows) def get_pagination_keyboard( @@ -1437,3 +1466,250 @@ def get_device_management_help_keyboard(language: str = DEFAULT_LANGUAGE) -> Inl ) ] ]) + + +# ==================== TICKET KEYBOARDS ==================== + +def get_ticket_cancel_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t("CANCEL_TICKET_CREATION", "❌ Отменить создание тикета"), + callback_data="cancel_ticket_creation" + ) + ] + ]) + + +def get_my_tickets_keyboard( + tickets: List[dict], + current_page: int = 1, + total_pages: int = 1, + language: str = DEFAULT_LANGUAGE +) -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [] + + for ticket in tickets: + status_emoji = ticket.get('status_emoji', '❓') + # Override status emoji for closed tickets in admin list + if ticket.get('is_closed', False): + status_emoji = '✅' + title = ticket.get('title', 'Без названия')[:25] + button_text = f"{status_emoji} #{ticket['id']} {title}" + + keyboard.append([ + InlineKeyboardButton( + text=button_text, + callback_data=f"view_ticket_{ticket['id']}" + ) + ]) + + # Пагинация + if total_pages > 1: + nav_row = [] + + if current_page > 1: + nav_row.append( + InlineKeyboardButton( + text=texts.t("PAGINATION_PREV", "⬅️"), + callback_data=f"my_tickets_page_{current_page - 1}" + ) + ) + + nav_row.append( + InlineKeyboardButton( + text=f"{current_page}/{total_pages}", + callback_data="current_page" + ) + ) + + if current_page < total_pages: + nav_row.append( + InlineKeyboardButton( + text=texts.t("PAGINATION_NEXT", "➡️"), + callback_data=f"my_tickets_page_{current_page + 1}" + ) + ) + + keyboard.append(nav_row) + + keyboard.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_support") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_ticket_view_keyboard( + ticket_id: int, + is_closed: bool = False, + language: str = DEFAULT_LANGUAGE +) -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [] + + if not is_closed: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("REPLY_TO_TICKET", "💬 Ответить"), + callback_data=f"reply_ticket_{ticket_id}" + ) + ]) + + if not is_closed: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CLOSE_TICKET", "🔒 Закрыть тикет"), + callback_data=f"close_ticket_{ticket_id}" + ) + ]) + + keyboard.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="my_tickets") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_ticket_reply_cancel_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t("CANCEL_REPLY", "❌ Отменить ответ"), + callback_data="cancel_ticket_reply" + ) + ] + ]) + + +# ==================== ADMIN TICKET KEYBOARDS ==================== + +def get_admin_tickets_keyboard( + tickets: List[dict], + current_page: int = 1, + total_pages: int = 1, + language: str = DEFAULT_LANGUAGE, + scope: str = "all" +) -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [] + + # Разделяем открытые/закрытые + open_rows = [] + closed_rows = [] + for ticket in tickets: + status_emoji = ticket.get('status_emoji', '❓') + if ticket.get('is_closed', False): + status_emoji = '✅' + user_name = ticket.get('user_name', 'Unknown')[:15] + title = ticket.get('title', 'Без названия')[:20] + locked_emoji = ticket.get('locked_emoji', '') + button_text = f"{status_emoji} #{ticket['id']} {locked_emoji} {user_name}: {title}".replace(" ", " ") + row = [InlineKeyboardButton(text=button_text, callback_data=f"admin_view_ticket_{ticket['id']}")] + if ticket.get('is_closed', False): + closed_rows.append(row) + else: + open_rows.append(row) + + # Scope switcher + switch_row = [] + switch_row.append(InlineKeyboardButton(text=texts.t("OPEN_TICKETS", "🔴 Открытые"), callback_data="admin_tickets_scope_open")) + switch_row.append(InlineKeyboardButton(text=texts.t("CLOSED_TICKETS", "🟢 Закрытые"), callback_data="admin_tickets_scope_closed")) + keyboard.append(switch_row) + + if open_rows and scope in ("all", "open"): + keyboard.append([InlineKeyboardButton(text=texts.t("OPEN_TICKETS_HEADER", "Открытые тикеты"), callback_data="noop")]) + keyboard.extend(open_rows) + if closed_rows and scope in ("all", "closed"): + keyboard.append([InlineKeyboardButton(text=texts.t("CLOSED_TICKETS_HEADER", "Закрытые тикеты"), callback_data="noop")]) + keyboard.extend(closed_rows) + + # Пагинация + if total_pages > 1: + nav_row = [] + + if current_page > 1: + nav_row.append( + InlineKeyboardButton( + text=texts.t("PAGINATION_PREV", "⬅️"), + callback_data=f"admin_tickets_page_{scope}_{current_page - 1}" + ) + ) + + nav_row.append( + InlineKeyboardButton( + text=f"{current_page}/{total_pages}", + callback_data="current_page" + ) + ) + + if current_page < total_pages: + nav_row.append( + InlineKeyboardButton( + text=texts.t("PAGINATION_NEXT", "➡️"), + callback_data=f"admin_tickets_page_{scope}_{current_page + 1}" + ) + ) + + keyboard.append(nav_row) + + keyboard.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_admin_ticket_view_keyboard( + ticket_id: int, + is_closed: bool = False, + language: str = DEFAULT_LANGUAGE +) -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [] + + if not is_closed: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("REPLY_TO_TICKET", "💬 Ответить"), + callback_data=f"admin_reply_ticket_{ticket_id}" + ) + ]) + + if not is_closed: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CLOSE_TICKET", "🔒 Закрыть тикет"), + callback_data=f"admin_close_ticket_{ticket_id}" + ) + ]) + + # Block controls: first row Unblock + Block forever, second row Block by time + keyboard.append([ + InlineKeyboardButton(text=texts.t("UNBLOCK", "✅ Разблокировать"), callback_data=f"admin_unblock_user_ticket_{ticket_id}"), + InlineKeyboardButton(text=texts.t("BLOCK_FOREVER", "🚫 Блок навсегда"), callback_data=f"admin_block_user_perm_ticket_{ticket_id}") + ]) + keyboard.append([ + InlineKeyboardButton(text=texts.t("BLOCK_BY_TIME", "⏳ Блокировка по времени"), callback_data=f"admin_block_user_ticket_{ticket_id}") + ]) + + keyboard.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="admin_tickets") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_admin_ticket_reply_cancel_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t("CANCEL_REPLY", "❌ Отменить ответ"), + callback_data="cancel_admin_ticket_reply" + ) + ] + ]) diff --git a/app/localization/texts.py b/app/localization/texts.py index 27553314..c8d65666 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -44,15 +44,12 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]: "TRAFFIC_250GB": f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", "TRAFFIC_UNLIMITED": f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", "SUPPORT_INFO": ( - "\n🛠️ Техническая поддержка\n\n" - "По всем вопросам обращайтесь к нашей поддержке:\n\n" - f"👤 {settings.SUPPORT_USERNAME}\n\n" - "Мы поможем с:\n" - "• Настройкой подключения\n" - "• Решением технических проблем \n" - "• Вопросами по оплате\n" - "• Другими вопросами\n\n" - "⏰ Время ответа: обычно в течение 1-2 часов\n" + "\n🛟 Поддержка RemnaWave\n\n" + "Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n" + "• 🎫 Создать тикет — опишите проблему или вопрос\n" + "• 📋 Мои тикеты — статус и переписка\n" + "• 💬 Связаться — написать напрямую (если нужно)\n\n" + "Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n" ), } @@ -72,15 +69,12 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]: "TRAFFIC_250GB": f"📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", "TRAFFIC_UNLIMITED": f"📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", "SUPPORT_INFO": ( - "\n🛠️ Technical support\n\n" - "For any questions contact our support:\n\n" - f"👤 {settings.SUPPORT_USERNAME}\n\n" - "We can help with:\n" - "• Connection setup\n" - "• Troubleshooting issues\n" - "• Payment questions\n" - "• Other requests\n\n" - "⏰ Response time: usually within 1-2 hours\n" + "\n🛟 RemnaWave Support\n\n" + "This is the ticket center: create requests, view replies and history.\n\n" + "• 🎫 Create ticket — describe your issue or question\n" + "• 📋 My tickets — status and conversation\n" + "• 💬 Contact — message directly if needed\n\n" + "Prefer tickets — it helps us respond faster and keep context.\n" ), } diff --git a/app/middlewares/throttling.py b/app/middlewares/throttling.py index a515c24a..b31b3cd7 100644 --- a/app/middlewares/throttling.py +++ b/app/middlewares/throttling.py @@ -4,6 +4,7 @@ import time from typing import Callable, Dict, Any, Awaitable from aiogram import BaseMiddleware from aiogram.types import Message, CallbackQuery, TelegramObject +from aiogram.fsm.context import FSMContext logger = logging.getLogger(__name__) @@ -33,13 +34,31 @@ class ThrottlingMiddleware(BaseMiddleware): if now - last_call < self.rate_limit: logger.warning(f"🚫 Throttling для пользователя {user_id}") - + + # Для сообщений: молчим только если это состояние работы с тикетами; иначе показываем блок if isinstance(event, Message): + try: + fsm: FSMContext = data.get("state") # может отсутствовать + current = await fsm.get_state() if fsm else None + except Exception: + current = None + is_ticket_state = False + if current: + # Молчим только в состояниях работы с тикетами (user/admin): waiting_for_message / waiting_for_reply + lowered = str(current) + is_ticket_state = ( + (":waiting_for_message" in lowered or ":waiting_for_reply" in lowered) and + ("TicketStates" in lowered or "AdminTicketStates" in lowered) + ) + if is_ticket_state: + return + # В остальных случаях — явный блок await event.answer("⏳ Пожалуйста, не отправляйте сообщения так часто!") + return + # Для callback допустим краткое уведомление elif isinstance(event, CallbackQuery): await event.answer("⏳ Слишком быстро! Подождите немного.", show_alert=True) - - return + return self.user_buckets[user_id] = now diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index c3913f92..24deb059 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -1,7 +1,7 @@ import logging from typing import Optional, Dict, Any, List from datetime import datetime -from aiogram import Bot +from aiogram import Bot, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from sqlalchemy.ext.asyncio import AsyncSession @@ -18,6 +18,7 @@ class AdminNotificationService: self.bot = bot self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None) self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None) + self.ticket_topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID', None) self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str: @@ -294,7 +295,7 @@ class AdminNotificationService: logger.error(f"Ошибка отправки уведомления о продлении: {e}") return False - async def _send_message(self, text: str) -> bool: + async def _send_message(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False) -> bool: if not self.chat_id: logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен") return False @@ -307,8 +308,16 @@ class AdminNotificationService: 'disable_web_page_preview': True } - if self.topic_id: - message_kwargs['message_thread_id'] = self.topic_id + # route to ticket-specific topic if provided + thread_id = None + if ticket_event and self.ticket_topic_id: + thread_id = self.ticket_topic_id + elif self.topic_id: + thread_id = self.topic_id + if thread_id: + message_kwargs['message_thread_id'] = thread_id + if reply_markup is not None: + message_kwargs['reply_markup'] = reply_markup await self.bot.send_message(**message_kwargs) logger.info(f"Уведомление отправлено в чат {self.chat_id}") @@ -801,3 +810,15 @@ class AdminNotificationService: return str(value) return str(value) + async def send_ticket_event_notification( + self, + text: str, + keyboard: types.InlineKeyboardMarkup | None = None + ) -> bool: + """Публичный метод для отправки уведомлений по тикетам в админ-топик. + Учитывает настройки включенности в settings. + """ + if not self._is_enabled(): + return False + return await self._send_message(text, reply_markup=keyboard, ticket_event=True) + diff --git a/app/services/support_settings_service.py b/app/services/support_settings_service.py new file mode 100644 index 00000000..19be0b10 --- /dev/null +++ b/app/services/support_settings_service.py @@ -0,0 +1,112 @@ +import json +import logging +from pathlib import Path +from typing import Dict + +from app.config import settings + + +logger = logging.getLogger(__name__) + + +class SupportSettingsService: + """Runtime editable support settings with JSON persistence.""" + + _storage_path: Path = Path("data/support_settings.json") + _data: Dict = {} + _loaded: bool = False + + @classmethod + def _ensure_dir(cls) -> None: + try: + cls._storage_path.parent.mkdir(parents=True, exist_ok=True) + except Exception as e: + logger.error(f"Failed to ensure settings dir: {e}") + + @classmethod + def _load(cls) -> None: + if cls._loaded: + return + cls._ensure_dir() + try: + if cls._storage_path.exists(): + cls._data = json.loads(cls._storage_path.read_text(encoding="utf-8")) + else: + cls._data = {} + except Exception as e: + logger.error(f"Failed to load support settings: {e}") + cls._data = {} + cls._loaded = True + + @classmethod + def _save(cls) -> bool: + cls._ensure_dir() + try: + cls._storage_path.write_text(json.dumps(cls._data, ensure_ascii=False, indent=2), encoding="utf-8") + return True + except Exception as e: + logger.error(f"Failed to save support settings: {e}") + return False + + # Mode + @classmethod + def get_system_mode(cls) -> str: + cls._load() + mode = (cls._data.get("system_mode") or settings.get_support_system_mode()).strip().lower() + return mode if mode in {"tickets", "contact", "both"} else "both" + + @classmethod + def set_system_mode(cls, mode: str) -> bool: + mode_clean = (mode or "").strip().lower() + if mode_clean not in {"tickets", "contact", "both"}: + return False + cls._load() + cls._data["system_mode"] = mode_clean + return cls._save() + + # Main menu visibility + @classmethod + def is_support_menu_enabled(cls) -> bool: + cls._load() + if "menu_enabled" in cls._data: + return bool(cls._data["menu_enabled"]) + return bool(settings.SUPPORT_MENU_ENABLED) + + @classmethod + def set_support_menu_enabled(cls, enabled: bool) -> bool: + cls._load() + cls._data["menu_enabled"] = bool(enabled) + return cls._save() + + # Contact vs tickets helpers + @classmethod + def is_tickets_enabled(cls) -> bool: + return cls.get_system_mode() in {"tickets", "both"} + + @classmethod + def is_contact_enabled(cls) -> bool: + return cls.get_system_mode() in {"contact", "both"} + + # Descriptions (per language) + @classmethod + def get_support_info_text(cls, language: str) -> str: + cls._load() + lang = (language or settings.DEFAULT_LANGUAGE).split("-")[0].lower() + overrides = cls._data.get("support_info_texts") or {} + text = overrides.get(lang) + if text and isinstance(text, str) and text.strip(): + return text + # Fallback to dynamic localization default + from app.localization.texts import get_texts + return get_texts(lang).SUPPORT_INFO + + @classmethod + def set_support_info_text(cls, language: str, text: str) -> bool: + cls._load() + lang = (language or settings.DEFAULT_LANGUAGE).split("-")[0].lower() + texts_map = cls._data.get("support_info_texts") or {} + texts_map[lang] = text or "" + cls._data["support_info_texts"] = texts_map + return cls._save() + + diff --git a/app/states.py b/app/states.py index 42b8a18c..2207c448 100644 --- a/app/states.py +++ b/app/states.py @@ -104,6 +104,18 @@ class AdminStates(StatesGroup): class SupportStates(StatesGroup): waiting_for_message = State() +class TicketStates(StatesGroup): + waiting_for_title = State() + waiting_for_message = State() + waiting_for_reply = State() + +class AdminTicketStates(StatesGroup): + waiting_for_reply = State() + waiting_for_block_duration = State() + +class SupportSettingsStates(StatesGroup): + waiting_for_desc = State() + class AutoPayStates(StatesGroup): setting_autopay_days = State() confirming_autopay_toggle = State() diff --git a/app/utils/message_patch.py b/app/utils/message_patch.py index 6e078318..63033321 100644 --- a/app/utils/message_patch.py +++ b/app/utils/message_patch.py @@ -1,5 +1,6 @@ from pathlib import Path from aiogram.types import Message, FSInputFile, InputMediaPhoto +from aiogram.exceptions import TelegramBadRequest from app.config import settings @@ -15,13 +16,39 @@ _original_edit_text = Message.edit_text async def _answer_with_photo(self: Message, text: str = None, **kwargs): + # Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ + if not settings.ENABLE_LOGO_MODE: + return await _original_answer(self, text, **kwargs) + # Если caption слишком длинный для фото — отправим как текст + try: + if text is not None and len(text) > 900: + return await _original_answer(self, text, **kwargs) + except Exception: + pass if LOGO_PATH.exists(): - return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs) + try: + return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs) + except Exception: + # Фоллбек, если Telegram ругается на caption: отправим как текст + return await _original_answer(self, text, **kwargs) return await _original_answer(self, text, **kwargs) async def _edit_with_photo(self: Message, text: str, **kwargs): + # Уважаем флаг в рантайме: если логотип выключен — не подменяем редактирование + if not settings.ENABLE_LOGO_MODE: + return await _original_edit_text(self, text, **kwargs) if self.photo: + # Если caption потенциально слишком длинный — отправим как текст вместо caption + try: + if text is not None and len(text) > 900: + try: + await self.delete() + except Exception: + pass + return await _original_answer(self, text, **kwargs) + except Exception: + pass # Всегда используем логотип если включен режим логотипа, # кроме специальных случаев (QR сообщения) if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not is_qr_message(self): @@ -32,8 +59,19 @@ async def _edit_with_photo(self: Message, text: str, **kwargs): media = self.photo[-1].file_id media_kwargs = {"media": media, "caption": text} if "parse_mode" in kwargs: - media_kwargs["parse_mode"] = kwargs.pop("parse_mode") - return await self.edit_media(InputMediaPhoto(**media_kwargs), **kwargs) + _pm = kwargs.pop("parse_mode") + media_kwargs["parse_mode"] = _pm if _pm is not None else "HTML" + else: + media_kwargs["parse_mode"] = "HTML" + try: + return await self.edit_media(InputMediaPhoto(**media_kwargs), **kwargs) + except TelegramBadRequest: + # Фоллбек: удалим и отправим обычный текст без фото + try: + await self.delete() + except Exception: + pass + return await _original_answer(self, text, **kwargs) return await _original_edit_text(self, text, **kwargs) diff --git a/app/utils/photo_message.py b/app/utils/photo_message.py index 90ed3052..53f65ea9 100644 --- a/app/utils/photo_message.py +++ b/app/utils/photo_message.py @@ -23,6 +23,7 @@ async def edit_or_answer_photo( keyboard: types.InlineKeyboardMarkup, parse_mode: str | None = "HTML", ) -> None: + # Если режим логотипа выключен — работаем текстом if not settings.ENABLE_LOGO_MODE: try: if callback.message.photo: @@ -47,17 +48,44 @@ async def edit_or_answer_photo( ) return + # Если текст слишком длинный для caption — отправим как текст + if caption and len(caption) > 1000: + try: + if callback.message.photo: + await callback.message.delete() + await callback.message.answer( + caption, + reply_markup=keyboard, + parse_mode=parse_mode, + ) + except TelegramBadRequest: + pass + return + media = _resolve_media(callback.message) try: await callback.message.edit_media( - InputMediaPhoto(media=media, caption=caption, parse_mode=parse_mode), + InputMediaPhoto(media=media, caption=caption, parse_mode=(parse_mode or "HTML")), reply_markup=keyboard, ) except TelegramBadRequest: - await callback.message.delete() - await callback.message.answer_photo( - FSInputFile(LOGO_PATH), - caption=caption, - reply_markup=keyboard, - parse_mode=parse_mode, - ) + # Фоллбек: если не удалось обновить фото — отправим текст, чтобы не упасть на лимите caption + try: + await callback.message.delete() + except Exception: + pass + try: + # Отправим как фото с логотипом + await callback.message.answer_photo( + photo=media if isinstance(media, FSInputFile) else FSInputFile(LOGO_PATH), + caption=caption, + reply_markup=keyboard, + parse_mode=(parse_mode or "HTML"), + ) + except Exception: + # Последний фоллбек — обычный текст + await callback.message.answer( + caption, + reply_markup=keyboard, + parse_mode=(parse_mode or "HTML"), + ) diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 00000000..61ad8a8a --- /dev/null +++ b/locales/en.json @@ -0,0 +1,439 @@ +{ + "ADD_COUNTRIES_BUTTON": "🌐 Add countries", + "ADMIN_MAIN_MENU": "🏠 Main menu", + "ADMIN_CAMPAIGNS": "📣 Promotional campaigns", + "AUTOPAY_BUTTON": "💳 Auto payment", + "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days", + "BACK": "⬅️ Back", + "BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription", + "BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}", + "CANCEL": "❌ Cancel", + "CHANGE_DEVICES_BUTTON": "📱 Change devices", + "CHANNEL_CHECK_BUTTON": "✅ I have joined", + "CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.", + "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe", + "CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!", + "CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing", + "CHECK_STATUS_BUTTON": "📊 Check status", + "CHOOSE_ANOTHER_DEVICE": "📱 Choose another device", + "CONFIRM": "✅ Confirm", + "CONFIRM_CHANGE_BUTTON": "✅ Confirm change", + "CONNECT_BUTTON": "🔗 Connect", + "CONTINUE": "➡️ Continue", + "CONTINUE_BUTTON": "➡️ Continue", + "COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link", + "CREATE_INVITE_BUTTON": "📝 Create invite", + "DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?", + "DEVICE_GUIDE_ANDROID": "🤖 Android", + "DEVICE_GUIDE_ANDROID_TV": "📺 Android TV", + "DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)", + "DEVICE_GUIDE_MAC": "🎯 macOS", + "DEVICE_GUIDE_WINDOWS": "💻 Windows", + "DISABLE_BUTTON": "❌ Disable", + "ENABLE_BUTTON": "✅ Enable", + "ERROR": "❌ An error occurred", + "ERROR_TRY_AGAIN": "❌ An error occurred. Please try again.", + "ERROR_RULES_RETRY": "An error occurred. Please try accepting the rules again:", + "GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up", + "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout", + "INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.", + "LANGUAGE_SELECTED": "🌐 Interface language set: English", + "LOADING": "⏳ Loading...", + "MAIN_MENU": "👤 {user_name}\n\n📱 Subscription: {subscription_status}\n\nChoose an option:\n", + "MAIN_MENU_ACTION_PROMPT": "Choose an option:", + "MAIN_MENU_BUTTON": "🏠 Main menu", + "MANAGE_DEVICES_BUTTON": "🔧 Manage devices", + "MENU_BALANCE": "💰 Balance", + "MENU_SUBSCRIPTION": "📱 Subscription", + "MENU_TRIAL": "🎁 Trial subscription", + "MY_BALANCE_BUTTON": "💰 My balance", + "MY_SUBSCRIPTION_BUTTON": "📱 My subscription", + "NO": "❌ No", + "NO_SERVERS_AVAILABLE": "❌ No servers available", + "NO_TRAFFIC_PACKAGES": "❌ No packages available", + "OTHER_APPS_BUTTON": "📋 Other apps", + "PAGINATION_NEXT": "➡️", + "PAGINATION_PREV": "⬅️", + "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable", + "PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)", + "PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)", + "PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)", + "PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)", + "PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars", + "PAYMENT_VIA_SUPPORT": "🛠️ Via support", + "PAY_NOW_BUTTON": "💳 Pay", + "PAY_WITH_COINS_BUTTON": "🪙 Pay", + "PENDING_CANCEL_BUTTON": "⌛ Cancel", + "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀", + "REFERRAL_ANALYTICS_BUTTON": "📊 Analytics", + "REFERRAL_CODE_ACCEPTED": "✅ Referral code accepted!", + "REFERRAL_CODE_INVALID": "❌ Invalid referral code", + "REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.", + "REFERRAL_CODE_QUESTION": "\n🤝 Do you have a friend's referral code?\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n", + "REFERRAL_CODE_SKIP": "⏭️ Skip", + "ALREADY_REGISTERED_REFERRAL": "ℹ️ You are already registered. A referral link cannot be applied.", + "REFERRAL_LIST_BUTTON": "👥 Referral list", + "RESET_ALL_DEVICES_BUTTON": "🔄 Reset all devices", + "RESET_DEVICE_CONFIRM_BUTTON": "✅ Reset this device", + "RESET_TRAFFIC_BUTTON": "🔄 Reset traffic", + "RULES_HEADER": "📋 Service Rules", + "RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...", + "RULES_TEXT_DEFAULT": "📋 Service Usage Rules\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\nBy using the service you agree to follow these rules.", + "SEND_CONTACT_BUTTON": "📱 Share contact", + "SEND_LOCATION_BUTTON": "📍 Share location", + "SHOW_QR_BUTTON": "📱 Show QR code", + "SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link", + "SKIP_BUTTON": "Skip ➡️", + "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings", + "SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days", + "SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)", + "SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!", + "SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!", + "SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}", + "SUB_STATUS_NONE": "❌ Not available", + "SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)", + "SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!", + "SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!", + "SUBSCRIPTION_ACTIVE": "✅ Active", + "SUCCESS": "✅ Success", + "REGISTRATION_COMPLETING": "✅ Completing registration...", + "SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic", + "TOPUP_BALANCE_BUTTON": "💳 Top up balance", + "TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured", + "TRIAL_ACTIVATE_BUTTON": "🎁 Activate", + "PROMOCODE_EMPTY_INPUT": "❌ Please enter a valid promo code", + "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.", + "STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.", + "STARS_PAYMENT_SUCCESS": "🎉 Payment processed successfully!\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀", + "STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.", + "STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.", + "STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.", + "STARS_PRECHECK_USER_NOT_FOUND": "User not found. Please contact support.", + "UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.", + "UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.", + "WELCOME": "\n🎉 Welcome to VPN Service!\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 Advantages:\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n", + "WELCOME_FALLBACK": "Welcome, {user_name}!", + "YES": "✅ Yes", + "ACCESS_DENIED": "❌ Access denied", + "ADMIN_MESSAGES": "📨 Broadcasts", + "ADMIN_MONITORING": "🔍 Monitoring", + "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", + "ADMIN_PROMOCODES": "🎫 Promo codes", + "ADMIN_REFERRALS": "🤝 Referral program", + "ADMIN_REMNAWAVE": "🖥️ Remnawave", + "ADMIN_RULES": "📋 Rules", + "ADMIN_STATISTICS": "📊 Statistics", + "ADMIN_PROMO_GROUPS": "💳 Promo groups", + "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", + "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", + "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", + "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", + "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", + "CREATE_TICKET_BUTTON": "🎫 Create ticket", + "MY_TICKETS_BUTTON": "📋 My tickets", + "CONTACT_SUPPORT_BUTTON": "💬 Contact support", + "TICKET_PRIORITY_SELECT": "Select ticket priority:", + "TICKET_PRIORITY_LOW": "🟢 Low", + "TICKET_PRIORITY_NORMAL": "🟡 Normal", + "TICKET_PRIORITY_HIGH": "🟠 High", + "TICKET_PRIORITY_URGENT": "🔴 Urgent", + "CANCEL_TICKET_CREATION": "❌ Cancel ticket creation", + "TICKET_TITLE_INPUT": "Enter ticket title:", + "TICKET_TITLE_TOO_SHORT": "Title must contain at least 5 characters. Try again:", + "TICKET_TITLE_TOO_LONG": "Title is too long. Maximum 255 characters. Try again:", + "TICKET_MESSAGE_INPUT": "Now describe your problem or question:", + "TICKET_MESSAGE_TOO_SHORT": "Message must contain at least 10 characters. Try again:", + "TICKET_CREATED_SUCCESS": "✅ Ticket #{ticket_id} created successfully!\n\nTitle: {title}\n\nWe will respond to you soon.", + "VIEW_TICKET": "👁️ View ticket", + "BACK_TO_MENU": "🏠 Back to menu", + "TICKET_CREATION_ERROR": "❌ An error occurred while creating the ticket. Please try again later.", + "NO_TICKETS": "You don't have any tickets yet.", + "MY_TICKETS_TITLE": "📋 Your tickets:", + "TICKET_STATUS_OPEN": "Open", + "TICKET_STATUS_ANSWERED": "Answered", + "TICKET_STATUS_CLOSED": "Closed", + "TICKET_STATUS_PENDING": "Pending", + "REPLY_TO_TICKET": "💬 Reply", + "CLOSE_TICKET": "🔒 Close ticket", + "CANCEL_REPLY": "❌ Cancel reply", + "TICKET_REPLY_INPUT": "Enter your reply:", + "TICKET_REPLY_TOO_SHORT": "Reply must contain at least 5 characters. Try again:", + "TICKET_REPLY_SENT": "✅ Your reply has been sent!", + "TICKET_REPLY_ERROR": "❌ An error occurred while sending the reply. Please try again later.", + "TICKET_CLOSED": "✅ Ticket closed.", + "TICKET_CLOSE_ERROR": "❌ Error closing ticket.", + "TICKET_NOT_FOUND": "Ticket not found.", + "TICKET_CREATION_CANCELLED": "Ticket creation cancelled.", + "BACK_TO_SUPPORT": "⬅️ Back to support", + "TICKET_REPLY_CANCELLED": "Reply cancelled.", + "BACK_TO_TICKETS": "⬅️ Back to tickets", + "NO_TICKETS_ADMIN": "No tickets to display.", + "ADMIN_TICKETS_TITLE": "🎫 All support tickets:", + "ADMIN_TICKET_REPLY_INPUT": "Enter support reply:", + + "ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!", + "TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.", + "TICKET_UPDATE_ERROR": "❌ Error updating ticket.", + "MARK_AS_ANSWERED": "✅ Mark as answered", + "TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:", + "CLOSE_NOTIFICATION": "❌ Close notification", + "NOTIFICATION_CLOSED": "Notification closed.", + "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group", + "ADMIN_USER_PROMO_GROUP_TITLE": "👥 User promo group", + "ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}", + "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.", + "ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:", + "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”", + "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.", + "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.", + "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user", + "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}", + "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", + "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", + "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", + "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", + "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete", + "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:", + "ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.", + "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", + "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", + "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", + "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", + "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", + "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", + "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", + "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", + "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", + "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.", + "ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.", + "ADMIN_SUBSCRIPTIONS": "📱 Subscriptions", + "ADMIN_USERS": "👥 Users", + "AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!", + "AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically", + "AUTOPAY_FAILED": "\n❌ Autopay failed\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n", + "AUTOPAY_SUCCESS": "\n✅ Autopay completed\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n", + "BALANCE_BUTTON": "💰 Balance: {balance}", + "BALANCE_BUTTON_ZERO": "💰 Balance: 0 ₽", + "BALANCE_HISTORY": "📊 Transaction history", + "BALANCE_INFO": "\n💰 Balance: {balance}\n\nChoose an action:\n", + "BALANCE_SUPPORT_REQUEST": "🛠️ Request via support", + "BALANCE_TOP_UP": "💳 Top up", + "CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.", + "CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!", + "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!", + "BUY_SUBSCRIPTION_START": "\n💎 Subscription setup\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n", + "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Your promo group discounts", + "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%", + "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%", + "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%", + "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:", + "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", + "CHANGE_DEVICES_CONFIRM": "\n📱 Confirm change\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", + "CHANGE_DEVICES_INFO": "\n📱 Adjust device limit\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 Important:\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n", + "CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n", + "CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n", + "CHANGE_DEVICES_TITLE": "📱 Change device limit", + "CONTACT_SUPPORT": "💬 Contact support", + "CREATE_INVITE": "📝 Create invite", + "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", + "DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})", + "DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}", + "DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed", + "INVALID_AMOUNT": "❌ Invalid amount", + "MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n", + "MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n", + "MENU_ADMIN": "⚙️ Admin panel", + "MENU_BUY_SUBSCRIPTION": "💎 Buy subscription", + "MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription", + "MENU_PROMOCODE": "🎫 Promo code", + "MENU_REFERRALS": "🤝 Referral program", + "MENU_RULES": "📋 Service rules", + "MENU_SUPPORT": "🛠️ Support", + "OPERATION_CANCELLED": "❌ Operation cancelled", + "PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}", + "PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}", + "PERIOD_60_DAYS": "📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}", + "PERIOD_90_DAYS": "📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}", + "PERIOD_180_DAYS": "📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}", + "PERIOD_360_DAYS": "📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}", + "PROMOCODE_ENTER": "🎫 Enter promo code", + "PROMOCODE_EXPIRED": "❌ Promo code has expired", + "PROMOCODE_INVALID": "❌ Invalid promo code", + "PROMOCODE_SUCCESS": "🎉 Promo code applied!", + "PROMOCODE_USED": "ℹ️ Promo code has already been used", + "REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.", + "REFERRAL_INFO": "\n🤝 Referral program\n\n👥 Invited: {referrals_count} friends\n💰 Earned: {earned_amount}\n\n🔗 Your referral link:\n{referral_link}\n\n🎫 Your promo code:\n{referral_code}\n\n💰 Terms:\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n", + "REFERRAL_INVITE_MESSAGE": "\n🎯 Invitation to the VPN service\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n", + "RULES_ACCEPT": "✅ I accept the rules", + "RULES_DECLINE": "❌ I do not accept", + "RULES_REQUIRED": "❗️ You must accept the rules to use the service!", + "SELECT_COUNTRIES": "Select countries:", + "SELECT_DEVICES": "Number of devices:", + "SELECT_PERIOD": "Choose period:", + "SELECT_TRAFFIC": "Choose traffic package:", + "SUBSCRIPTION_EXPIRED": "\n❌ Subscription expired\n\nYour subscription has ended. Renew it to restore access.\n", + "SUBSCRIPTION_EXPIRING": "\n⚠️ Subscription expiring!\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n", + "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Subscription expires in {days_text}!\n\nYour paid subscription ends on {end_date}.\n\n💳 Autopay: {autopay_status}\n\n{action_text}\n", + "SUBSCRIPTION_INFO": "\n📱 Subscription details\n\n📊 Status: {status}\n🎭 Type: {type}\n📅 Valid until: {end_date}\n⏰ Days left: {days_left}\n\n📈 Traffic: {traffic_used} / {traffic_limit}\n🌍 Servers: {countries_count} countries\n📱 Devices: {devices_used} / {devices_limit}\n\n💳 Autopay: {autopay_status}\n", + "SUBSCRIPTION_NONE": "❌ No active subscription", + "SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found", + "SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!", + "SUBSCRIPTION_SUMMARY": "\n📋 Final configuration\n\n📅 Period: {period} days\n📈 Traffic: {traffic}\n🌍 Countries: {countries}\n📱 Devices: {devices}\n\n💰 Total: {total_price}\n\nConfirm the purchase?\n", + "SUBSCRIPTION_TRIAL": "🧪 Trial subscription", + "SUPPORT_INFO": "\n🛠️ Technical support\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n", + "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Confirm traffic change\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", + "SWITCH_TRAFFIC_INFO": "\n🔄 Switch traffic limit\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 Important:\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n", + "SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Traffic limit decreased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\nℹ️ Payments are not refunded\n", + "SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Traffic limit increased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\n💰 Charged: {amount}\n", + "SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit", + "TOP_UP_AMOUNT": "💳 Enter top-up amount (in rubles):", + "TOP_UP_METHODS": "\n💳 Select a payment method\n\nAmount: {amount}\n", + "TOP_UP_STARS": "⭐ Telegram Stars", + "TOP_UP_TRIBUTE": "💎 Bank card", + "TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", + "TRAFFIC_10GB": "📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", + "TRAFFIC_25GB": "📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}", + "TRAFFIC_50GB": "📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}", + "TRAFFIC_100GB": "📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", + "TRAFFIC_250GB": "📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", + "TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", + "TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", + "TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed", + "TRIAL_ACTIVATED": "🎉 Trial subscription activated!", + "TRIAL_ALREADY_USED": "❌ The trial subscription has already been used", + "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic} GB\n📱 Devices: {devices} pcs\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", + "TRIAL_ENDING_SOON": "\n🎁 The trial subscription is ending soon!\n\nYour trial expires in a few hours.\n\n💎 Don't want to lose VPN access?\nSwitch to the full subscription!\n\n🔥 Special offer:\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n", + "USER_NOT_FOUND": "❌ User not found", + "MENU_LANGUAGE": "🌐 Language", + "SUBSCRIPTION_STATUS_EXPIRED": "Expired", + "SUBSCRIPTION_STATUS_TRIAL": "Trial", + "SUBSCRIPTION_STATUS_ACTIVE": "Active", + "SUBSCRIPTION_STATUS_UNKNOWN": "Unknown", + "SUBSCRIPTION_TIME_LEFT_EXPIRED": "expired", + "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} days", + "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} hr", + "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} min", + "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ expires tomorrow!", + "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ expires today!", + "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 expires in a few minutes!", + "SUBSCRIPTION_TYPE_TRIAL": "Trial", + "SUBSCRIPTION_TYPE_PAID": "Paid", + "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (unlimited) | Used: {used} GB", + "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} GB", + "SUBSCRIPTION_NO_SERVERS": "No servers", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Connected devices:\n", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", + "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Connection link:\n{subscription_url}", + "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app", + "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Your import link for the VPN app:\n{subscription_url}", + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device", + "BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu", + "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured", + "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.", + "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", + "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", + "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", + "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", + "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", + "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", + "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", + "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", + "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", + "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", + "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", + "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Step 2 - Add subscription:", + "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Step 3 - Connect:", + "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 How to connect:", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server", + "SUBSCRIPTION_APPS_TITLE": "📱 Apps for {device_name}", + "SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:", + "SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found", + "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", + "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", + "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 How to use:", + "SUBSCRIPTION_LINK_STEP1": "1. Tap the link above to copy it", + "SUBSCRIPTION_LINK_STEP2": "2. Open your VPN app", + "SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option", + "SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link", + "SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.", + "REFERRAL_PROGRAM_TITLE": "👥 Referral program", + "REFERRAL_STATS_HEADER": "📊 Your statistics:", + "REFERRAL_STATS_INVITED": "• Invited users: {count}", + "REFERRAL_STATS_FIRST_TOPUPS": "• Made first top-up: {count}", + "REFERRAL_STATS_ACTIVE": "• Active referrals: {count}", + "REFERRAL_STATS_CONVERSION": "• Conversion: {rate}%", + "REFERRAL_STATS_TOTAL_EARNED": "• Earned in total: {amount}", + "REFERRAL_STATS_MONTH_EARNED": "• Earned last month: {amount}", + "REFERRAL_REWARDS_HEADER": "🎁 How rewards work:", + "REFERRAL_REWARD_NEW_USER": "• New user receives: {bonus} on the first top-up from {minimum}", + "REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: {bonus}", + "REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: {percent}%", + "REFERRAL_LINK_TITLE": "🔗 Your referral link:", + "REFERRAL_CODE_TITLE": "🆔 Your code: {code}", + "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Latest rewards:", + "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up", + "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Top-up commission", + "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Purchase commission", + "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} from {referral_name}", + "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Earnings by type:", + "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Bonuses for first top-ups: {count} ({amount})", + "REFERRAL_EARNINGS_TOPUPS": "• Top-up commissions: {count} ({amount})", + "REFERRAL_EARNINGS_PURCHASES": "• Purchase commissions: {count} ({amount})", + "REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!", + "REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}", + "REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!", + "REFERRAL_LIST_HEADER": "👥 Your referrals (page {current}/{total})", + "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", + "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Top-ups: {count}", + "REFERRAL_LIST_ITEM_EARNED": " 💎 Earned from them: {amount}", + "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago", + "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago", + "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago", + "REFERRAL_LIST_PREV_PAGE": "⬅️ Back", + "REFERRAL_LIST_NEXT_PAGE": "Next ➡️", + "REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics", + "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Earnings by period:", + "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Today: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• Week: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• Month: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• Quarter: {amount}", + "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Top {count} referrals:", + "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} rewards)", + "REFERRAL_ANALYTICS_FOOTER": "📈 Keep growing your referral network!", + "REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!", + "REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!", + "REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection", + "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Servers worldwide", + "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection", + "REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:", + "REFERRAL_SHARE_BUTTON": "📤 Share", + "REFERRAL_INVITE_CREATED_TITLE": "📝 Invitation created!", + "REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:", + "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Balance top-up methods\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:", + "PAYMENT_METHODS_TITLE": "💳 Balance top-up methods", + "PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:", + "PAYMENT_METHODS_FOOTER": "Choose a top-up method:", + "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", + "PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient", + "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Bank card", + "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa", + "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Bank card", + "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", + "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", + "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", + "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", + "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance." +} diff --git a/locales/ru.json b/locales/ru.json new file mode 100644 index 00000000..328c0d33 --- /dev/null +++ b/locales/ru.json @@ -0,0 +1,450 @@ +{ + "ACCESS_DENIED": "❌ Доступ запрещен", + "ADD_COUNTRIES_BUTTON": "🌐 Добавить страны", + "ADMIN_MAIN_MENU": "🏠 Главное меню", + "ADMIN_CAMPAIGNS": "📣 Рекламные кампании", + "ADMIN_MESSAGES": "📨 Рассылки", + "ADMIN_MONITORING": "🔍 Мониторинг", + "ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n", + "ADMIN_PROMOCODES": "🎫 Промокоды", + "ADMIN_REFERRALS": "🤝 Партнерка", + "ADMIN_REMNAWAVE": "🖥️ Remnawave", + "ADMIN_RULES": "📋 Правила", + "ADMIN_STATISTICS": "📊 Статистика", + "ADMIN_PROMO_GROUPS": "💳 Промогруппы", + "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", + "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", + "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", + "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", + "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", + "CREATE_TICKET_BUTTON": "🎫 Создать тикет", + "MY_TICKETS_BUTTON": "📋 Мои тикеты", + "CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой", + "TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:", + "TICKET_PRIORITY_LOW": "🟢 Низкий", + "TICKET_PRIORITY_NORMAL": "🟡 Обычный", + "TICKET_PRIORITY_HIGH": "🟠 Высокий", + "TICKET_PRIORITY_URGENT": "🔴 Срочный", + "CANCEL_TICKET_CREATION": "❌ Отменить создание тикета", + "TICKET_TITLE_INPUT": "Введите заголовок тикета:", + "TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:", + "TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:", + "TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото без текста:", + "TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:", + "TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.", + "VIEW_TICKET": "👁️ Посмотреть тикет", + "BACK_TO_MENU": "🏠 В главное меню", + "TICKET_CREATION_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.", + "NO_TICKETS": "У вас пока нет тикетов.", + "MY_TICKETS_TITLE": "📋 Ваши тикеты:", + "TICKET_STATUS_OPEN": "Открыт", + "TICKET_STATUS_ANSWERED": "Отвечен", + "TICKET_STATUS_CLOSED": "Закрыт", + "TICKET_STATUS_PENDING": "В ожидании", + "REPLY_TO_TICKET": "💬 Ответить", + "CLOSE_TICKET": "🔒 Закрыть тикет", + "CANCEL_REPLY": "❌ Отменить ответ", + "TICKET_REPLY_INPUT": "Введите ваш ответ:", + "TICKET_REPLY_TOO_SHORT": "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:", + "TICKET_REPLY_SENT": "✅ Ваш ответ отправлен!", + "TICKET_REPLY_ERROR": "❌ Произошла ошибка при отправке ответа. Попробуйте позже.", + "TICKET_CLOSED": "✅ Тикет закрыт.", + "TICKET_CLOSE_ERROR": "❌ Ошибка при закрытии тикета.", + "TICKET_NOT_FOUND": "Тикет не найден.", + "TICKET_CREATION_CANCELLED": "Создание тикета отменено.", + "BACK_TO_SUPPORT": "⬅️ К поддержке", + "TICKET_REPLY_CANCELLED": "Ответ отменен.", + "BACK_TO_TICKETS": "⬅️ К тикетам", + "NO_TICKETS_ADMIN": "Нет тикетов для отображения.", + "ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:", + "ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:", + + "ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!", + "TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.", + "TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.", + "MARK_AS_ANSWERED": "✅ Отметить как отвеченный", + "TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:", + "CLOSE_NOTIFICATION": "❌ Закрыть уведомление", + "NOTIFICATION_CLOSED": "Уведомление закрыто.", + "UNBLOCK": "✅ Разблокировать", + "BLOCK_FOREVER": "🚫 Блок навсегда", + "BLOCK_BY_TIME": "⏳ Блокировка по времени", + "TICKET_ATTACHMENTS": "📎 Вложения", + "OPEN_TICKETS": "🔴 Открытые", + "CLOSED_TICKETS": "🟢 Закрытые", + "OPEN_TICKETS_HEADER": "🔴 Открытые тикеты", + "SENDING_ATTACHMENTS": "📎 Отправляю вложения...", + "NO_ATTACHMENTS": "Вложений нет.", + "ATTACHMENTS_SENT": "✅ Вложения отправлены.", + "DELETE_MESSAGE": "🗑 Удалить", + "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа", + "ADMIN_USER_PROMO_GROUP_TITLE": "👥 Промогруппа пользователя", + "ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}", + "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.", + "ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:", + "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»", + "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.", + "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.", + "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", + "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}", + "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", + "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", + "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", + "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", + "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить", + "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:", + "ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.", + "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", + "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", + "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", + "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", + "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", + "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", + "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", + "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", + "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", + "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.", + "ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.", + "ADMIN_SUBSCRIPTIONS": "📱 Подписки", + "ADMIN_USERS": "👥 Пользователи", + "AUTOPAY_BUTTON": "💳 Автоплатёж", + "AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!", + "AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически", + "AUTOPAY_FAILED": "\n❌ Ошибка автоплатежа\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n", + "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни", + "AUTOPAY_SUCCESS": "\n✅ Автоплатеж выполнен\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n", + "BACK": "⬅️ Назад", + "BACK_TO_SUBSCRIPTION": "⬅️ К подписке", + "BALANCE_BUTTON": "💰 Баланс: {balance}", + "BALANCE_BUTTON_DEFAULT": "💰 Баланс: {balance}", + "BALANCE_BUTTON_ZERO": "💰 Баланс: 0 ₽", + "BALANCE_HISTORY": "📊 История операций", + "BALANCE_INFO": "\n💰 Баланс: {balance}\n\nВыберите действие:\n", + "BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку", + "BALANCE_TOP_UP": "💳 Пополнить", + "CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.", + "CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!", + "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!", + "BUY_SUBSCRIPTION_START": "\n💎 Настройка подписки\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n", + "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Скидки вашей промогруппы", + "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%", + "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%", + "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%", + "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:", + "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", + "CANCEL": "❌ Отмена", + "CHANGE_DEVICES_BUTTON": "📱 Изменить устройства", + "CHANGE_DEVICES_CONFIRM": "\n 📱 Подтверждение изменения\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ", + "CHANGE_DEVICES_INFO": "\n 📱 Изменение количества устройств\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 Важно:\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ", + "CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ", + "CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ", + "CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств", + "CHANNEL_CHECK_BUTTON": "✅ Я подписался", + "CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.", + "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться", + "CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!", + "CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку", + "CHECK_STATUS_BUTTON": "📊 Проверить статус", + "CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство", + "CONFIRM": "✅ Подтвердить", + "CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение", + "CONNECT_BUTTON": "🔗 Подключиться", + "CONTACT_SUPPORT": "💬 Написать в поддержку", + "CONTINUE": "➡️ Продолжить", + "CONTINUE_BUTTON": "✅ Продолжить", + "COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки", + "CREATE_INVITE": "📝 Создать приглашение", + "CREATE_INVITE_BUTTON": "📝 Создать приглашение", + "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}", + "DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})", + "DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}", + "DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось", + "DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?", + "DEVICE_GUIDE_ANDROID": "🤖 Android", + "DEVICE_GUIDE_ANDROID_TV": "📺 Android TV", + "DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)", + "DEVICE_GUIDE_MAC": "🎯 macOS", + "DEVICE_GUIDE_WINDOWS": "💻 Windows", + "DISABLE_BUTTON": "❌ Выключить", + "ENABLE_BUTTON": "✅ Включить", + "ERROR": "❌ Произошла ошибка", + "ERROR_TRY_AGAIN": "❌ Произошла ошибка. Попробуйте еще раз.", + "ERROR_RULES_RETRY": "Произошла ошибка. Попробуйте принять правила еще раз:", + "GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса", + "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки", + "INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n Пополните баланс на {amount} и попробуйте снова.\n ", + "INVALID_AMOUNT": "❌ Неверная сумма", + "LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: Русский", + "LOADING": "⏳ Загрузка...", + "MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n", + "MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n", + "MAIN_MENU": "👤 {user_name}\n \n📱 Подписка: {subscription_status}\n\nВыберите действие:\n", + "MAIN_MENU_ACTION_PROMPT": "Выберите действие:", + "MAIN_MENU_BUTTON": "🏠 Главное меню", + "MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами", + "MENU_ADMIN": "⚙️ Админ-панель", + "MENU_BALANCE": "💰 Баланс", + "MENU_BUY_SUBSCRIPTION": "💎 Купить подписку", + "MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку", + "MENU_LANGUAGE": "🌐 Язык", + "MENU_PROMOCODE": "🎫 Промокод", + "MENU_REFERRALS": "🤝 Партнерка", + "MENU_RULES": "📋 Правила сервиса", + "MENU_SUBSCRIPTION": "📱 Подписка", + "MENU_SUPPORT": "🛠️ Техподдержка", + "MENU_TRIAL": "🧪 Тестовая подписка", + "MY_BALANCE_BUTTON": "💰 Мой баланс", + "MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка", + "NO": "❌ Нет", + "NO_SERVERS_AVAILABLE": "❌ Нет доступных серверов", + "NO_TRAFFIC_PACKAGES": "❌ Нет доступных пакетов", + "OPERATION_CANCELLED": "❌ Операция отменена", + "OTHER_APPS_BUTTON": "📋 Другие приложения", + "PAGINATION_NEXT": "➡️", + "PAGINATION_PREV": "⬅️", + "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны", + "PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)", + "PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)", + "PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)", + "PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)", + "PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars", + "PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку", + "PAY_NOW_BUTTON": "💳 Оплатить", + "PAY_WITH_COINS_BUTTON": "🪙 Оплатить", + "PENDING_CANCEL_BUTTON": "⌛ Отмена", + "PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}", + "PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}", + "PERIOD_30_DAYS": "📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}", + "PERIOD_360_DAYS": "📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}", + "PERIOD_60_DAYS": "📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}", + "PERIOD_90_DAYS": "📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}", + "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Подключиться бесплатно 🚀", + "PROMOCODE_ENTER": "🎫 Введите промокод:", + "PROMOCODE_EMPTY_INPUT": "❌ Введите корректный промокод", + "PROMOCODE_EXPIRED": "❌ Промокод истек", + "PROMOCODE_INVALID": "❌ Неверный промокод", + "PROMOCODE_SUCCESS": "🎉 Промокод активирован! {description}", + "PROMOCODE_USED": "❌ Промокод уже использован", + "REFERRAL_ANALYTICS_BUTTON": "📊 Аналитика", + "REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.", + "REFERRAL_CODE_ACCEPTED": "✅ Реферальный код принят!", + "REFERRAL_CODE_INVALID": "❌ Неверный реферальный код", + "REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start", + "REFERRAL_CODE_QUESTION": "\n🤝 У вас есть реферальный код от друга?\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n", + "REFERRAL_CODE_SKIP": "⏭️ Пропустить", + "ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", + "REFERRAL_INFO": "\n🤝 Реферальная программа\n\n👥 Приглашено: {referrals_count} друзей\n💰 Заработано: {earned_amount}\n\n🔗 Ваша реферальная ссылка:\n{referral_link}\n\n🎫 Ваш промокод:\n{referral_code}\n\n💰 Условия:\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n", + "REFERRAL_INVITE_MESSAGE": "\n🎯 Приглашение в VPN сервис\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n", + "REFERRAL_LIST_BUTTON": "👥 Список рефералов", + "RESET_ALL_DEVICES_BUTTON": "🔄 Сбросить все устройства", + "RESET_DEVICE_CONFIRM_BUTTON": "✅ Да, сбросить это устройство", + "RESET_TRAFFIC_BUTTON": "🔄 Сбросить трафик", + "RULES_ACCEPT": "✅ Принимаю правила", + "RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...", + "RULES_DECLINE": "❌ Не принимаю", + "RULES_HEADER": "📋 Правила сервиса", + "RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!", + "RULES_TEXT_DEFAULT": "📋 Правила использования сервиса\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\nИспользуя сервис, вы подтверждаете согласие с этими правилами.", + "SELECT_COUNTRIES": "Выберите страны:", + "SELECT_DEVICES": "Количество устройств:", + "SELECT_PERIOD": "Выберите период:", + "SELECT_TRAFFIC": "Выберите пакет трафика:", + "SEND_CONTACT_BUTTON": "📱 Отправить контакт", + "SEND_LOCATION_BUTTON": "📍 Отправить геолокацию", + "SHOW_QR_BUTTON": "📱 Показать QR код", + "SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки", + "SKIP_BUTTON": "⏭️ Пропустить", + "SUBSCRIPTION_ACTIVE": "✅ Активна", + "SUBSCRIPTION_EXPIRED": "\n❌ Подписка истекла\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n", + "SUBSCRIPTION_EXPIRING": "\n⚠️ Подписка истекает!\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n", + "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Подписка истекает через {days_text}!\n\nВаша платная подписка истекает {end_date}.\n\n💳 Автоплатеж: {autopay_status}\n\n{action_text}\n", + "SUBSCRIPTION_INFO": "\n📱 Информация о подписке\n\n📊 Статус: {status}\n🎭 Тип: {type}\n📅 Действует до: {end_date}\n⏰ Осталось дней: {days_left}\n\n📈 Трафик: {traffic_used} / {traffic_limit}\n🌍 Серверы: {countries_count} стран\n📱 Устройства: {devices_used} / {devices_limit}\n\n💳 Автоплатеж: {autopay_status}\n", + "SUBSCRIPTION_NONE": "❌ Нет активной подписки", + "SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена", + "SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!", + "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки", + "SUBSCRIPTION_SUMMARY": "\n📋 Итоговая конфигурация\n\n📅 Период: {period} дней\n📈 Трафик: {traffic}\n🌍 Страны: {countries}\n📱 Устройства: {devices}\n\n💰 Итого к оплате: {total_price}\n\nПодтвердить покупку?\n", + "SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка", + "SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Активна\n⚠️ истекает через {days} дн.", + "SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)", + "SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!", + "SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!", + "SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}", + "SUB_STATUS_NONE": "❌ Отсутствует", + "SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)", + "SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!", + "SUB_STATUS_TRIAL_TOMORROW": "🎁 Тестовая подписка\n⚠️ истекает завтра!", + "SUCCESS": "✅ Успешно", + "REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...", + "SUPPORT_INFO": "\n🛠️ Техническая поддержка\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n", + "SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик", + "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Подтверждение переключения трафика\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n", + "SWITCH_TRAFFIC_INFO": "\n🔄 Переключение лимита трафика\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 Важно:\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n", + "SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Лимит трафика уменьшен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\nℹ️ Возврат средств не производится\n", + "SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Лимит трафика увеличен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\n💰 Списано: {amount}\n", + "SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика", + "TOPUP_BALANCE_BUTTON": "💳 Попол\\у043Dить баланс", + "TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):", + "TOP_UP_METHODS": "\n💳 Выберите способ оплаты\n\nСумма: {amount}\n", + "TOP_UP_STARS": "⭐ Telegram Stars", + "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.", + "STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.", + "STARS_PAYMENT_SUCCESS": "🎉 Платеж успешно обработан!\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀", + "STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.", + "STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.", + "STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.", + "STARS_PRECHECK_USER_NOT_FOUND": "Пользователь не найден. Обратитесь в поддержку.", + "TOP_UP_TRIBUTE": "💎 Банковская карта", + "TRAFFIC_100GB": "📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", + "TRAFFIC_10GB": "📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", + "TRAFFIC_250GB": "📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", + "TRAFFIC_25GB": "📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}", + "TRAFFIC_50GB": "📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}", + "TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", + "TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}", + "TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился", + "TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены", + "TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", + "TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!", + "TRIAL_ACTIVATE_BUTTON": "🎁 Активировать", + "TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована", + "TRIAL_AVAILABLE": "\n🎁 Тестовая подписка\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ Период: {days} дней\n📈 Трафик: {traffic} ГБ\n📱 Устройства: {devices} шт.\n🌍 Сервер: {server_name}\n\nАктивировать тестовую подписку?\n", + "TRIAL_ENDING_SOON": "\n🎁 Тестовая подписка скоро закончится!\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 Не хотите остаться без VPN?\nПереходите на полную подписку!\n\n🔥 Специальное предложение:\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n", + "UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.", + "UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.", + "USER_NOT_FOUND": "❌ Пользователь не найден", + "WELCOME": "\n🎉 Добро пожаловать в VPN сервис!\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 Преимущества:\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n", + "WELCOME_FALLBACK": "Добро пожаловать, {user_name}!", + "YES": "✅ Да", + "SUBSCRIPTION_STATUS_EXPIRED": "Истекла", + "SUBSCRIPTION_STATUS_TRIAL": "Тестовая", + "SUBSCRIPTION_STATUS_ACTIVE": "Активна", + "SUBSCRIPTION_STATUS_UNKNOWN": "Неизвестно", + "SUBSCRIPTION_TIME_LEFT_EXPIRED": "истёк", + "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} дн.", + "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} ч.", + "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} мин.", + "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ истекает завтра!", + "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ истекает сегодня!", + "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 истекает через несколько минут!", + "SUBSCRIPTION_TYPE_TRIAL": "Триал", + "SUBSCRIPTION_TYPE_PAID": "Платная", + "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (безлимит) | Использовано: {used} ГБ", + "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} ГБ", + "SUBSCRIPTION_NO_SERVERS": "Нет серверов", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Подключенные устройства:\n", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", + "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Ссылка для подключения:\n{subscription_url}", + "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", + "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню", + "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена", + "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", + "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", + "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", + "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", + "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", + "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", + "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", + "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", + "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", + "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", + "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", + "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", + "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Шаг 2 - Добавление подписки:", + "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Шаг 3 - Подключение:", + "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 Как подключить:", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу", + "SUBSCRIPTION_APPS_TITLE": "📱 Приложения для {device_name}", + "SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:", + "SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено", + "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", + "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", + "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 Как использовать:", + "SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать", + "SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение", + "SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"", + "SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку", + "SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", + "REFERRAL_PROGRAM_TITLE": "👥 Реферальная программа", + "REFERRAL_STATS_HEADER": "📊 Ваша статистика:", + "REFERRAL_STATS_INVITED": "• Приглашено пользователей: {count}", + "REFERRAL_STATS_FIRST_TOPUPS": "• Сделали первое пополнение: {count}", + "REFERRAL_STATS_ACTIVE": "• Активных рефералов: {count}", + "REFERRAL_STATS_CONVERSION": "• Конверсия: {rate}%", + "REFERRAL_STATS_TOTAL_EARNED": "• Заработано всего: {amount}", + "REFERRAL_STATS_MONTH_EARNED": "• За последний месяц: {amount}", + "REFERRAL_REWARDS_HEADER": "🎁 Как работают награды:", + "REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: {bonus} при первом пополнении от {minimum}", + "REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: {bonus}", + "REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: {percent}%", + "REFERRAL_LINK_TITLE": "🔗 Ваша реферальная ссылка:", + "REFERRAL_CODE_TITLE": "🆔 Ваш код: {code}", + "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Последние начисления:", + "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение", + "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Комиссия с пополнения", + "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Комиссия с покупки", + "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} от {referral_name}", + "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Доходы по типам:", + "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Бонусы за первые пополнения: {count} ({amount})", + "REFERRAL_EARNINGS_TOPUPS": "• Комиссии с пополнений: {count} ({amount})", + "REFERRAL_EARNINGS_PURCHASES": "• Комиссии с покупок: {count} ({amount})", + "REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!", + "REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}", + "REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!", + "REFERRAL_LIST_HEADER": "👥 Ваши рефералы (стр. {current}/{total})", + "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", + "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Пополнений: {count}", + "REFERRAL_LIST_ITEM_EARNED": " 💎 Заработано с него: {amount}", + "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад", + "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад", + "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно", + "REFERRAL_LIST_PREV_PAGE": "⬅️ Назад", + "REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️", + "REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов", + "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Доходы по периодам:", + "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Сегодня: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• За неделю: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• За месяц: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• За квартал: {amount}", + "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Топ-{count} рефералов:", + "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)", + "REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!", + "REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!", + "REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!", + "REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение", + "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру", + "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита", + "REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:", + "REFERRAL_SHARE_BUTTON": "📤 Поделиться", + "REFERRAL_INVITE_CREATED_TITLE": "📝 Приглашение создано!", + "REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:", + "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Способы пополнения баланса\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:", + "PAYMENT_METHODS_TITLE": "💳 Способы пополнения баланса", + "PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:", + "PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:", + "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", + "PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно", + "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банковская карта", + "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", + "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банковская карта", + "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", + "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", + "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", + "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", + "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку." +} From 15bda0560af61c4240d8bd8cb97109a681353705 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 23 Sep 2025 15:39:16 +0300 Subject: [PATCH 002/146] =?UTF-8?q?feat:=20=D0=BC=D0=BE=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F,=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BD=D0=BE=D0=B5=20=D0=BC=D0=B5=D0=BD=D1=8E?= =?UTF-8?q?=20=D1=82=D0=B8=D0=BA=D0=B5=D1=82=D0=BE=D0=B2,=20SLA=20=D0=B8?= =?UTF-8?q?=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config.py | 5 + app/database/crud/ticket.py | 92 +++++- app/database/models.py | 19 ++ app/database/universal_migration.py | 97 +++++++ app/handlers/admin/main.py | 126 +++++++++ app/handlers/admin/support_settings.py | 240 +++++++++++++++- app/handlers/admin/tickets.py | 312 ++++++++++++++++++--- app/handlers/menu.py | 3 + app/handlers/tickets.py | 104 ++++--- app/keyboards/admin.py | 20 +- app/keyboards/inline.py | 42 ++- app/localization/locales/en.json | 1 + app/localization/locales/ru.json | 3 + app/services/admin_notification_service.py | 8 +- app/services/monitoring_service.py | 117 +++++++- app/services/support_settings_service.py | 109 +++++++ locales/ru.json | 4 +- 17 files changed, 1191 insertions(+), 111 deletions(-) diff --git a/app/config.py b/app/config.py index e157f84c..e0075783 100644 --- a/app/config.py +++ b/app/config.py @@ -16,6 +16,11 @@ class Settings(BaseSettings): SUPPORT_MENU_ENABLED: bool = True SUPPORT_SYSTEM_MODE: str = "both" # one of: tickets, contact, both SUPPORT_MENU_ENABLED: bool = True + # SLA for support tickets + SUPPORT_TICKET_SLA_ENABLED: bool = True + SUPPORT_TICKET_SLA_MINUTES: int = 5 + SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS: int = 60 + SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES: int = 15 ADMIN_NOTIFICATIONS_ENABLED: bool = False ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None diff --git a/app/database/crud/ticket.py b/app/database/crud/ticket.py index add26178..6f566cdc 100644 --- a/app/database/crud/ticket.py +++ b/app/database/crud/ticket.py @@ -4,7 +4,7 @@ from sqlalchemy import select, desc, and_, or_, update, func from sqlalchemy.orm import selectinload from datetime import datetime -from app.database.models import Ticket, TicketMessage, TicketStatus, User +from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog class TicketCRUD: @@ -87,6 +87,40 @@ class TicketCRUD: result = await db.execute(query) return result.scalars().all() + @staticmethod + async def count_user_tickets_by_statuses( + db: AsyncSession, + user_id: int, + statuses: List[str] + ) -> int: + """Подсчитать количество тикетов пользователя по списку статусов""" + query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user_id) + if statuses: + query = query.where(Ticket.status.in_(statuses)) + result = await db.execute(query) + return int(result.scalar() or 0) + + @staticmethod + async def get_user_tickets_by_statuses( + db: AsyncSession, + user_id: int, + statuses: List[str], + limit: int = 20, + offset: int = 0 + ) -> List[Ticket]: + """Получить тикеты пользователя по списку статусов с пагинацией""" + query = ( + select(Ticket) + .where(Ticket.user_id == user_id) + .order_by(desc(Ticket.updated_at)) + .offset(offset) + .limit(limit) + ) + if statuses: + query = query.where(Ticket.status.in_(statuses)) + result = await db.execute(query) + return result.scalars().all() + @staticmethod async def user_has_active_ticket( db: AsyncSession, @@ -239,6 +273,54 @@ class TicketCRUD: return await TicketCRUD.update_ticket_status( db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow() ) + + @staticmethod + async def add_support_audit( + db: AsyncSession, + *, + actor_user_id: Optional[int], + actor_telegram_id: int, + is_moderator: bool, + action: str, + ticket_id: Optional[int] = None, + target_user_id: Optional[int] = None, + details: Optional[dict] = None, + ) -> None: + try: + log = SupportAuditLog( + actor_user_id=actor_user_id, + actor_telegram_id=actor_telegram_id, + is_moderator=bool(is_moderator), + action=action, + ticket_id=ticket_id, + target_user_id=target_user_id, + details=details or {}, + ) + db.add(log) + await db.commit() + except Exception: + await db.rollback() + # не мешаем основной логике + pass + + @staticmethod + async def list_support_audit( + db: AsyncSession, + *, + limit: int = 50, + offset: int = 0, + ) -> List[SupportAuditLog]: + from sqlalchemy import select, desc + result = await db.execute( + select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit) + ) + return result.scalars().all() + + @staticmethod + async def count_support_audit(db: AsyncSession) -> int: + from sqlalchemy import select, func + result = await db.execute(select(func.count()).select_from(SupportAuditLog)) + return int(result.scalar() or 0) @staticmethod async def get_open_tickets_count(db: AsyncSession) -> int: @@ -291,6 +373,14 @@ class TicketMessageCRUD: else: # Пользователь ответил - тикет открыт ticket.status = TicketStatus.OPEN.value + # Сбросить отметку последнего SLA-напоминания, чтобы снова напоминать от времени нового сообщения + try: + from sqlalchemy import inspect as sa_inspect + # если колонка существует в модели + if hasattr(ticket, 'last_sla_reminder_at'): + ticket.last_sla_reminder_at = None + except Exception: + pass ticket.updated_at = datetime.utcnow() diff --git a/app/database/models.py b/app/database/models.py index 2491eef2..b1e3014d 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -703,6 +703,23 @@ class SubscriptionServer(Base): subscription = relationship("Subscription", backref="subscription_servers") server_squad = relationship("ServerSquad", backref="subscription_servers") + +class SupportAuditLog(Base): + __tablename__ = "support_audit_logs" + + id = Column(Integer, primary_key=True, index=True) + actor_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + actor_telegram_id = Column(BigInteger, nullable=False) + is_moderator = Column(Boolean, default=False) + action = Column(String(50), nullable=False) # close_ticket, block_user_timed, block_user_perm, unblock_user + ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True) + target_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + details = Column(JSON, nullable=True) + created_at = Column(DateTime, default=func.now()) + + actor = relationship("User", foreign_keys=[actor_user_id]) + ticket = relationship("Ticket", foreign_keys=[ticket_id]) + class UserMessage(Base): __tablename__ = "user_messages" id = Column(Integer, primary_key=True, index=True) @@ -810,6 +827,8 @@ class Ticket(Base): created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) closed_at = Column(DateTime, nullable=True) + # SLA reminders + last_sla_reminder_at = Column(DateTime, nullable=True) # Связи user = relationship("User", backref="tickets") diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 2b1136ca..36c88c45 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -815,6 +815,30 @@ async def add_ticket_reply_block_columns(): logger.error(f"Ошибка добавления колонок блокировок в tickets: {e}") return False + +async def add_ticket_sla_columns(): + try: + col_exists = await check_column_exists('tickets', 'last_sla_reminder_at') + if col_exists: + return True + async with engine.begin() as conn: + db_type = await get_database_type() + if db_type == 'sqlite': + alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at DATETIME NULL" + elif db_type == 'postgresql': + alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at TIMESTAMP NULL" + elif db_type == 'mysql': + alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at DATETIME NULL" + else: + logger.error(f"Неподдерживаемый тип БД для добавления last_sla_reminder_at: {db_type}") + return False + await conn.execute(text(alter_sql)) + logger.info("✅ Добавлена колонка tickets.last_sla_reminder_at") + return True + except Exception as e: + logger.error(f"Ошибка добавления SLA колонки в tickets: {e}") + return False + async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: @@ -1107,6 +1131,79 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением полей блокировок в tickets") + logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ SLA В TICKETS ===") + sla_cols_added = await add_ticket_sla_columns() + if sla_cols_added: + logger.info("✅ Поля SLA в tickets готовы") + else: + logger.warning("⚠️ Проблемы с добавлением полей SLA в tickets") + + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===") + try: + async with engine.begin() as conn: + db_type = await get_database_type() + if not await check_table_exists('support_audit_logs'): + if db_type == 'sqlite': + create_sql = """ + CREATE TABLE support_audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_user_id INTEGER NULL, + actor_telegram_id BIGINT NOT NULL, + is_moderator BOOLEAN NOT NULL DEFAULT 0, + action VARCHAR(50) NOT NULL, + ticket_id INTEGER NULL, + target_user_id INTEGER NULL, + details JSON NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (actor_user_id) REFERENCES users(id), + FOREIGN KEY (ticket_id) REFERENCES tickets(id), + FOREIGN KEY (target_user_id) REFERENCES users(id) + ); + CREATE INDEX idx_support_audit_logs_ticket ON support_audit_logs(ticket_id); + CREATE INDEX idx_support_audit_logs_actor ON support_audit_logs(actor_telegram_id); + CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action); + """ + elif db_type == 'postgresql': + create_sql = """ + CREATE TABLE support_audit_logs ( + id SERIAL PRIMARY KEY, + actor_user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL, + actor_telegram_id BIGINT NOT NULL, + is_moderator BOOLEAN NOT NULL DEFAULT FALSE, + action VARCHAR(50) NOT NULL, + ticket_id INTEGER NULL REFERENCES tickets(id) ON DELETE SET NULL, + target_user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL, + details JSON NULL, + created_at TIMESTAMP DEFAULT NOW() + ); + CREATE INDEX idx_support_audit_logs_ticket ON support_audit_logs(ticket_id); + CREATE INDEX idx_support_audit_logs_actor ON support_audit_logs(actor_telegram_id); + CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action); + """ + else: + create_sql = """ + CREATE TABLE support_audit_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + actor_user_id INT NULL, + actor_telegram_id BIGINT NOT NULL, + is_moderator BOOLEAN NOT NULL DEFAULT 0, + action VARCHAR(50) NOT NULL, + ticket_id INT NULL, + target_user_id INT NULL, + details JSON NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX idx_support_audit_logs_ticket ON support_audit_logs(ticket_id); + CREATE INDEX idx_support_audit_logs_actor ON support_audit_logs(actor_telegram_id); + CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action); + """ + await conn.execute(text(create_sql)) + logger.info("✅ Таблица support_audit_logs создана") + else: + logger.info("ℹ️ Таблица support_audit_logs уже существует") + except Exception as e: + logger.warning(f"⚠️ Проблемы с созданием таблицы support_audit_logs: {e}") + logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===") promo_groups_ready = await ensure_promo_groups_setup() if promo_groups_ready: diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py index dfce24d7..a26fd7b3 100644 --- a/app/handlers/admin/main.py +++ b/app/handlers/admin/main.py @@ -10,14 +10,18 @@ from app.keyboards.admin import ( get_admin_users_submenu_keyboard, get_admin_promo_submenu_keyboard, get_admin_communications_submenu_keyboard, + get_admin_support_submenu_keyboard, get_admin_settings_submenu_keyboard, get_admin_system_submenu_keyboard ) from app.localization.texts import get_texts from app.handlers.admin import support_settings as support_settings_handlers from app.utils.decorators import admin_required, error_handler +from app.services.support_settings_service import SupportSettingsService from app.database.crud.rules import clear_all_rules, get_rules_statistics from app.localization.texts import clear_rules_cache +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from app.database.crud.ticket import TicketCRUD logger = logging.getLogger(__name__) @@ -113,6 +117,115 @@ async def show_communications_submenu( await callback.answer() +@admin_required +@error_handler +async def show_support_submenu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + texts = get_texts(db_user.language) + # Moderators have access only to tickets and not to settings + is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id)) + + from app.keyboards.admin import get_admin_support_submenu_keyboard + kb = get_admin_support_submenu_keyboard(db_user.language) + if is_moderator_only: + # Rebuild keyboard to include only tickets and back to main menu + kb = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")] + ]) + await callback.message.edit_text( + "🛟 **Поддержка**\n\n" + ("Доступ к тикетам." if is_moderator_only else "Управление тикетами и настройками поддержки:"), + reply_markup=kb, + parse_mode="Markdown" + ) + await callback.answer() + + +# Moderator panel entry (from main menu quick button) +async def show_moderator_panel( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + kb = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")], + [InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")] + ]) + await callback.message.edit_text( + "🧑‍⚖️ Модерация поддержки\n\nДоступ к тикетам поддержки.", + parse_mode="HTML", + reply_markup=kb + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_support_audit( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + # pagination + page = 1 + if callback.data.startswith("admin_support_audit_page_"): + try: + page = int(callback.data.split("_")[-1]) + except Exception: + page = 1 + per_page = 10 + total = await TicketCRUD.count_support_audit(db) + total_pages = max(1, (total + per_page - 1) // per_page) + if page < 1: + page = 1 + if page > total_pages: + page = total_pages + offset = (page - 1) * per_page + logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset) + + lines = ["🧾 Аудит модераторов", ""] + if not logs: + lines.append("Пока пусто") + else: + for log in logs: + role = "Модератор" if getattr(log, 'is_moderator', False) else "Админ" + ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else '' + action_map = { + 'close_ticket': 'Закрытие тикета', + 'block_user_timed': 'Блокировка (время)', + 'block_user_perm': 'Блокировка (навсегда)', + 'unblock_user': 'Снятие блока', + } + action_text = action_map.get(log.action, log.action) + ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else "" + details = log.details or {} + extra = "" + if log.action == 'block_user_timed' and 'minutes' in details: + extra = f" ({details['minutes']} мин)" + lines.append(f"{ts} • {role} {log.actor_telegram_id} — {action_text}{ticket_part}{extra}") + + # keyboard with pagination + nav_row = [] + if total_pages > 1: + if page > 1: + nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"admin_support_audit_page_{page-1}")) + nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page")) + if page < total_pages: + nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"admin_support_audit_page_{page+1}")) + + kb_rows = [] + if nav_row: + kb_rows.append(nav_row) + kb_rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_support")]) + kb = InlineKeyboardMarkup(inline_keyboard=kb_rows) + + await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb) + await callback.answer() + + @admin_required @error_handler async def show_settings_submenu( @@ -285,6 +398,15 @@ def register_handlers(dp: Dispatcher): F.data == "admin_submenu_communications" ) + dp.callback_query.register( + show_support_submenu, + F.data == "admin_submenu_support" + ) + dp.callback_query.register( + show_support_audit, + F.data.in_(["admin_support_audit"]) | F.data.startswith("admin_support_audit_page_") + ) + dp.callback_query.register( show_settings_submenu, F.data == "admin_submenu_settings" @@ -294,6 +416,10 @@ def register_handlers(dp: Dispatcher): show_system_submenu, F.data == "admin_submenu_system" ) + dp.callback_query.register( + show_moderator_panel, + F.data == "moderator_panel" + ) # Support settings module support_settings_handlers.register_handlers(dp) diff --git a/app/handlers/admin/support_settings.py b/app/handlers/admin/support_settings.py index eb93e841..adbf3ea7 100644 --- a/app/handlers/admin/support_settings.py +++ b/app/handlers/admin/support_settings.py @@ -7,6 +7,7 @@ from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User +from app.config import settings from app.localization.texts import get_texts from app.utils.decorators import admin_required, error_handler from app.services.support_settings_service import SupportSettingsService @@ -20,6 +21,10 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup: texts = get_texts(language) mode = SupportSettingsService.get_system_mode() menu_enabled = SupportSettingsService.is_support_menu_enabled() + admin_notif = SupportSettingsService.get_admin_ticket_notifications_enabled() + user_notif = SupportSettingsService.get_user_ticket_notifications_enabled() + sla_enabled = SupportSettingsService.get_sla_enabled() + sla_minutes = SupportSettingsService.get_sla_minutes() rows: list[list[types.InlineKeyboardButton]] = [] @@ -40,8 +45,53 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup: types.InlineKeyboardButton(text="📝 Изменить описание", callback_data="admin_support_edit_desc") ]) + # Notifications block rows.append([ - types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications") + types.InlineKeyboardButton( + text=("🔔 Админ-уведомления: Включены" if admin_notif else "🔕 Админ-уведомления: Отключены"), + callback_data="admin_support_toggle_admin_notifications" + ) + ]) + rows.append([ + types.InlineKeyboardButton( + text=("🔔 Пользовательские уведомления: Включены" if user_notif else "🔕 Пользовательские уведомления: Отключены"), + callback_data="admin_support_toggle_user_notifications" + ) + ]) + + # SLA block + rows.append([ + types.InlineKeyboardButton( + text=("⏰ SLA: Включено" if sla_enabled else "⏹️ SLA: Отключено"), + callback_data="admin_support_toggle_sla" + ) + ]) + rows.append([ + types.InlineKeyboardButton( + text=f"⏳ Время SLA: {sla_minutes} мин", + callback_data="admin_support_set_sla_minutes" + ) + ]) + + # Moderators + moderators = SupportSettingsService.get_moderators() + mod_count = len(moderators) + rows.append([ + types.InlineKeyboardButton( + text=f"🧑‍⚖️ Модераторы: {mod_count}", callback_data="admin_support_list_moderators" + ) + ]) + rows.append([ + types.InlineKeyboardButton( + text="➕ Назначить модератора", callback_data="admin_support_add_moderator" + ), + types.InlineKeyboardButton( + text="➖ Удалить модератора", callback_data="admin_support_remove_moderator" + ) + ]) + + rows.append([ + types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support") ]) return types.InlineKeyboardMarkup(inline_keyboard=rows) @@ -78,6 +128,175 @@ async def toggle_support_menu( await show_support_settings(callback, db_user, db) +@admin_required +@error_handler +async def toggle_admin_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + current = SupportSettingsService.get_admin_ticket_notifications_enabled() + SupportSettingsService.set_admin_ticket_notifications_enabled(not current) + await show_support_settings(callback, db_user, db) + + +@admin_required +@error_handler +async def toggle_user_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + current = SupportSettingsService.get_user_ticket_notifications_enabled() + SupportSettingsService.set_user_ticket_notifications_enabled(not current) + await show_support_settings(callback, db_user, db) + + +@admin_required +@error_handler +async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + current = SupportSettingsService.get_sla_enabled() + SupportSettingsService.set_sla_enabled(not current) + await show_support_settings(callback, db_user, db) + + +from app.states import SupportSettingsStates + +@admin_required +@error_handler +async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + await callback.message.edit_text( + "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):", + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + ) + await state.set_state(SupportSettingsStates.waiting_for_desc) # temporary reuse replaced below + # we'll manage separate state below + + +from aiogram.fsm.state import State, StatesGroup + +class SupportAdvancedStates(StatesGroup): + waiting_for_sla_minutes = State() + waiting_for_moderator_id = State() + + +@admin_required +@error_handler +async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + await callback.message.edit_text( + "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):", + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + ) + await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes) + await callback.answer() + + +@admin_required +@error_handler +async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): + text = (message.text or "").strip() + try: + minutes = int(text) + if minutes <= 0 or minutes > 1440: + raise ValueError() + except Exception: + await message.answer("❌ Введите корректное число минут (1-1440)") + return + SupportSettingsService.set_sla_minutes(minutes) + await state.clear() + markup = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + await message.answer("✅ Значение SLA сохранено", reply_markup=markup) + + +@admin_required +@error_handler +async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + await callback.message.edit_text( + "🧑‍⚖️ Назначение модератора\n\nОтправьте Telegram ID пользователя (число)", + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + ) + await state.set_state(SupportAdvancedStates.waiting_for_moderator_id) + await callback.answer() + + +@admin_required +@error_handler +async def handle_add_moderator(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): + text = (message.text or "").strip() + try: + tid = int(text) + except Exception: + await message.answer("❌ Введите корректный Telegram ID (число)") + return + if SupportSettingsService.add_moderator(tid): + markup = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + await message.answer(f"✅ Пользователь {tid} назначен модератором", reply_markup=markup) + else: + await message.answer("❌ Не удалось сохранить") + await state.clear() + + +@admin_required +@error_handler +async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + await callback.message.edit_text( + "🧑‍⚖️ Удаление модератора\n\nОтправьте Telegram ID пользователя (число)", + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + ) + await state.set_state(SupportAdvancedStates.waiting_for_moderator_id) + # We'll reuse the same state; next message will decide action via flag + await state.update_data(action="remove_moderator") + await callback.answer() + + +@admin_required +@error_handler +async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): + data = await state.get_data() + action = data.get("action", "add") + text = (message.text or "").strip() + try: + tid = int(text) + except Exception: + await message.answer("❌ Введите корректный Telegram ID (число)") + return + ok = False + if action == "remove_moderator": + ok = SupportSettingsService.remove_moderator(tid) + msg = "✅ Модератор удалён" if ok else "❌ Не удалось удалить" + else: + ok = SupportSettingsService.add_moderator(tid) + msg = "✅ Пользователь назначен модератором" if ok else "❌ Не удалось назначить" + await state.clear() + markup = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + await message.answer(msg, reply_markup=markup) + + +@admin_required +@error_handler +async def list_moderators(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + moderators = SupportSettingsService.get_moderators() + if not moderators: + await callback.answer("Список пуст", show_alert=True) + return + text = "🧑‍⚖️ Модераторы\n\n" + "\n".join([f"• {tid}" for tid in moderators]) + markup = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup) + await callback.answer() + + @admin_required @error_handler async def set_mode_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession): @@ -177,9 +396,17 @@ async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: Async await callback.answer("Текст отправлен ниже") -@admin_required @error_handler async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + # Allow admins and moderators to delete informational notifications + try: + may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)) + except Exception: + may_delete = False + if not may_delete: + texts = get_texts(db_user.language if db_user else 'ru') + await callback.answer(texts.ACCESS_DENIED, show_alert=True) + return try: await callback.message.delete() finally: @@ -196,6 +423,15 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(start_edit_desc, F.data == "admin_support_edit_desc") dp.callback_query.register(send_desc_copy, F.data == "admin_support_send_desc") dp.callback_query.register(delete_sent_message, F.data == "admin_support_delete_msg") + dp.callback_query.register(toggle_admin_notifications, F.data == "admin_support_toggle_admin_notifications") + dp.callback_query.register(toggle_user_notifications, F.data == "admin_support_toggle_user_notifications") + dp.callback_query.register(toggle_sla, F.data == "admin_support_toggle_sla") + dp.callback_query.register(start_set_sla_minutes, F.data == "admin_support_set_sla_minutes") + dp.callback_query.register(start_add_moderator, F.data == "admin_support_add_moderator") + dp.callback_query.register(start_remove_moderator, F.data == "admin_support_remove_moderator") + dp.callback_query.register(list_moderators, F.data == "admin_support_list_moderators") dp.message.register(handle_new_desc, SupportSettingsStates.waiting_for_desc) + dp.message.register(handle_sla_minutes, SupportAdvancedStates.waiting_for_sla_minutes) + dp.message.register(handle_moderator_id, SupportAdvancedStates.waiting_for_moderator_id) diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py index c943a5db..dcaab6d2 100644 --- a/app/handlers/admin/tickets.py +++ b/app/handlers/admin/tickets.py @@ -1,6 +1,7 @@ import logging -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional from aiogram import Dispatcher, types, F, Bot +from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, and_ @@ -18,6 +19,7 @@ from app.keyboards.inline import ( from app.localization.texts import get_texts from app.utils.pagination import paginate_list, get_pagination_info from app.services.admin_notification_service import AdminNotificationService +from app.services.support_settings_service import SupportSettingsService from app.config import settings from app.utils.cache import RateLimitCache @@ -33,6 +35,11 @@ async def show_admin_tickets( db: AsyncSession ): """Показать все тикеты для админов""" + # permission gate: admin or active moderator only + 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 texts = get_texts(db_user.language) # Определяем текущую страницу и scope @@ -59,6 +66,8 @@ async def show_admin_tickets( # total count for proper pagination total_count = await TicketCRUD.count_tickets_by_statuses(db, statuses) total_pages = max(1, (total_count + page_size - 1) // page_size) if total_count > 0 else 1 + if current_page < 1: + current_page = 1 if current_page > total_pages: current_page = total_pages offset = (current_page - 1) * page_size @@ -81,9 +90,33 @@ async def show_admin_tickets( }) # Итоговые страницы уже посчитаны выше - await callback.message.edit_text( - texts.t("ADMIN_TICKETS_TITLE", "🎫 Все тикеты поддержки:"), - reply_markup=get_admin_tickets_keyboard(ticket_data, current_page=current_page, total_pages=total_pages, language=db_user.language, scope=scope) + header_text = ( + texts.t("ADMIN_TICKETS_TITLE_OPEN", "🎫 Открытые тикеты поддержки:") + if scope == "open" + else texts.t("ADMIN_TICKETS_TITLE_CLOSED", "🎫 Закрытые тикеты поддержки:") + ) + # Determine proper back target for moderators + back_cb = "admin_submenu_support" + try: + if not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id): + back_cb = "moderator_panel" + except Exception: + pass + + keyboard = get_admin_tickets_keyboard( + ticket_data, + current_page=current_page, + total_pages=total_pages, + language=db_user.language, + scope=scope, + back_callback=back_cb, + ) + from app.utils.photo_message import edit_or_answer_photo + await edit_or_answer_photo( + callback=callback, + caption=header_text, + keyboard=keyboard, + parse_mode="HTML", ) await callback.answer() @@ -92,10 +125,29 @@ async def view_admin_ticket( callback: types.CallbackQuery, db_user: User, db: AsyncSession, - state: FSMContext + state: Optional[FSMContext] = None ): """Показать детали тикета для админа""" - ticket_id = int(callback.data.replace("admin_view_ticket_", "")) + 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 + data_str = callback.data or "" + ticket_id = None + try: + if data_str.startswith("admin_view_ticket_"): + ticket_id = int(data_str.replace("admin_view_ticket_", "")) + else: + ticket_id = int(data_str.split("_")[-1]) + except Exception: + ticket_id = None + if ticket_id is None: + texts = get_texts(db_user.language) + 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, load_user=True) @@ -145,9 +197,10 @@ async def view_admin_ticket( # Добавим кнопку "Вложения", если есть фото 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, - ticket.is_closed, - db_user.language + ticket_id, + ticket.is_closed, + db_user.language, + is_user_blocked=ticket.is_user_reply_blocked ) if has_photos: try: @@ -155,23 +208,20 @@ async def view_admin_ticket( except Exception: pass - # Сначала пробуем отредактировать; если не вышло — удалим и отправим новое - try: - await callback.message.edit_text( - ticket_text, - reply_markup=keyboard, - ) - except Exception: + # Рендер через фото-утилиту (с логотипом), внутри есть фоллбеки на текст + 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 state is not None: try: - await callback.message.delete() + await state.update_data(ticket_id=ticket_id) except Exception: pass - await callback.message.answer( - ticket_text, - reply_markup=keyboard, - ) - # сохраняем id для дальнейших действий (ответ/статусы) - await state.update_data(ticket_id=ticket_id) await callback.answer() @@ -181,6 +231,10 @@ async def reply_to_admin_ticket( db_user: User ): """Начать ответ на тикет от админа""" + 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 ticket_id = int(callback.data.replace("admin_reply_ticket_", "")) await state.update_data(ticket_id=ticket_id, reply_mode=True) @@ -200,6 +254,11 @@ async def handle_admin_ticket_reply( db_user: User, db: AsyncSession ): + if not (settings.is_admin(message.from_user.id) or SupportSettingsService.is_moderator(message.from_user.id)): + texts = get_texts(db_user.language) + await message.answer(texts.ACCESS_DENIED) + await state.clear() + return # Проверяем, что пользователь в правильном состоянии current_state = await state.get_state() if current_state != AdminTicketStates.waiting_for_reply: @@ -373,17 +432,42 @@ async def close_admin_ticket( db: AsyncSession ): """Закрыть тикет админом""" + 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 ticket_id = int(callback.data.replace("admin_close_ticket_", "")) try: success = await TicketCRUD.close_ticket(db, ticket_id) if success: + # audit + try: + is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id)) + await TicketCRUD.add_support_audit( + db, + actor_user_id=db_user.id if db_user else None, + actor_telegram_id=callback.from_user.id, + is_moderator=is_mod, + action="close_ticket", + ticket_id=ticket_id, + target_user_id=None, + details={} + ) + except Exception: + pass texts = get_texts(db_user.language) - await callback.answer( - texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), - show_alert=True - ) + # Notify with deletable inline message + try: + await callback.message.answer( + texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + ) + except Exception: + await callback.answer(texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), show_alert=True) # Обновляем inline-клавиатуру в текущем сообщении без кнопок действий await callback.message.edit_reply_markup( @@ -411,6 +495,10 @@ async def cancel_admin_ticket_reply( db_user: User ): """Отменить ответ админа на тикет""" + 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 await state.clear() texts = get_texts(db_user.language) @@ -433,13 +521,22 @@ async def block_user_in_ticket( db_user: User, db: AsyncSession ): + 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 ticket_id = int(callback.data.replace("admin_block_user_ticket_", "")) texts = get_texts(db_user.language) + # Save original ticket message ids to update it after blocking without reopening + try: + await state.update_data(origin_chat_id=callback.message.chat.id, origin_message_id=callback.message.message_id) + except Exception: + pass await callback.message.edit_text( texts.t("ENTER_BLOCK_MINUTES", "Введите количество минут для блокировки пользователя (например, 15):"), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton( - text=texts.t("CANCEL_REPLY", "❌ Отменить ответ"), + text=texts.t("CANCEL_REPLY", "❌ Отменить ввод"), callback_data="cancel_admin_ticket_reply" )] ]) @@ -455,6 +552,12 @@ async def handle_admin_block_duration_input( db_user: User, db: AsyncSession ): + # permission gate for message flow + if not (settings.is_admin(message.from_user.id) or SupportSettingsService.is_moderator(message.from_user.id)): + texts = get_texts(db_user.language) + await message.answer(texts.ACCESS_DENIED) + await state.clear() + return # Проверяем состояние current_state = await state.get_state() if current_state != AdminTicketStates.waiting_for_block_duration: @@ -467,6 +570,8 @@ async def handle_admin_block_duration_input( data = await state.get_data() ticket_id = data.get("ticket_id") + origin_chat_id = data.get("origin_chat_id") + origin_message_id = data.get("origin_message_id") try: minutes = int(reply_text) minutes = max(1, min(60*24*365, minutes)) # максимум 1 год @@ -490,15 +595,76 @@ async def handle_admin_block_duration_input( until = datetime.utcnow() + timedelta(minutes=minutes) ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=until) - if ok: - await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") - else: + if not ok: await message.answer("❌ Ошибка блокировки") - await state.clear() - await message.answer( - "✅ Блокировка установлена. Откройте тикет заново для обновления состояния.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[[types.InlineKeyboardButton(text="👁️ Посмотреть тикет", callback_data=f"admin_view_ticket_{ticket_id}")]]) - ) + return + # audit + try: + is_mod = (not settings.is_admin(message.from_user.id) and SupportSettingsService.is_moderator(message.from_user.id)) + await TicketCRUD.add_support_audit( + db, + actor_user_id=db_user.id if db_user else None, + actor_telegram_id=message.from_user.id, + is_moderator=is_mod, + action="block_user_timed", + ticket_id=ticket_id, + target_user_id=ticket.user_id if ticket else None, + details={"minutes": minutes} + ) + except Exception: + pass + # Refresh original ticket card (caption/text and buttons) in place + try: + updated = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True) + 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(updated.status, updated.status) + user_name = updated.user.full_name if updated.user else "Unknown" + ticket_text = f"🎫 Тикет #{updated.id}\n\n" + ticket_text += f"👤 Пользователь: {user_name}\n" + ticket_text += f"📝 Заголовок: {updated.title}\n" + ticket_text += f"📊 Статус: {updated.status_emoji} {status_text}\n" + ticket_text += f"📅 Создан: {updated.created_at.strftime('%d.%m.%Y %H:%M')}\n" + ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n" + if updated.is_user_reply_blocked: + if updated.user_reply_block_permanent: + ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n" + elif updated.user_reply_block_until: + ticket_text += f"⏳ Блок до: {updated.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n" + if updated.messages: + ticket_text += f"💬 Сообщения ({len(updated.messages)}):\n\n" + for msg in updated.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" + if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo": + ticket_text += "📎 Вложение: фото\n\n" + + kb = get_admin_ticket_view_keyboard(updated.id, updated.is_closed, db_user.language, is_user_blocked=updated.is_user_reply_blocked) + has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in updated.messages or []) + if has_photos: + try: + kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{updated.id}")]) + except Exception: + pass + if origin_chat_id and origin_message_id: + try: + await message.bot.edit_message_caption(chat_id=origin_chat_id, message_id=origin_message_id, caption=ticket_text, reply_markup=kb, parse_mode="HTML") + except Exception: + try: + await message.bot.edit_message_text(chat_id=origin_chat_id, message_id=origin_message_id, text=ticket_text, reply_markup=kb, parse_mode="HTML") + except Exception: + await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") + else: + await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") + except Exception: + await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") + finally: + await state.clear() except Exception as e: logger.error(f"Error setting block duration: {e}") texts = get_texts(db_user.language) @@ -515,11 +681,39 @@ async def unblock_user_in_ticket( db_user: User, db: AsyncSession ): + 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 ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", "")) ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=None) if ok: - await callback.answer("✅ Блок снят") - await view_admin_ticket(callback, db_user, db, FSMContext(callback.bot, callback.from_user.id)) + try: + await callback.message.answer( + "✅ Блок снят", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + ) + except Exception: + await callback.answer("✅ Блок снят") + # audit + try: + is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id)) + ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", "")) + await TicketCRUD.add_support_audit( + db, + actor_user_id=db_user.id if db_user else None, + actor_telegram_id=callback.from_user.id, + is_moderator=is_mod, + action="unblock_user", + ticket_id=ticket_id, + target_user_id=None, + details={} + ) + except Exception: + pass + await view_admin_ticket(callback, db_user, db) else: await callback.answer("❌ Ошибка", show_alert=True) @@ -529,11 +723,38 @@ async def block_user_permanently( db_user: User, db: AsyncSession ): + 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 ticket_id = int(callback.data.replace("admin_block_user_perm_ticket_", "")) ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=True, until=None) if ok: - await callback.answer("✅ Пользователь заблокирован навсегда") - await view_admin_ticket(callback, db_user, db, FSMContext(callback.bot, callback.from_user.id)) + try: + await callback.message.answer( + "✅ Пользователь заблокирован навсегда", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + ) + except Exception: + await callback.answer("✅ Пользователь заблокирован") + # audit + try: + is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id)) + await TicketCRUD.add_support_audit( + db, + actor_user_id=db_user.id if db_user else None, + actor_telegram_id=callback.from_user.id, + is_moderator=is_mod, + action="block_user_perm", + ticket_id=ticket_id, + target_user_id=None, + details={} + ) + except Exception: + pass + await view_admin_ticket(callback, db_user, db) else: await callback.answer("❌ Ошибка", show_alert=True) @@ -541,6 +762,12 @@ async def block_user_permanently( async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: str, db: AsyncSession): """Уведомить пользователя о новом ответе в тикете""" try: + # Respect runtime toggle for user ticket notifications + try: + if not SupportSettingsService.get_user_ticket_notifications_enabled(): + return + except Exception: + pass from app.localization.texts import get_texts # Получаем тикет с пользователем @@ -638,6 +865,11 @@ def register_handlers(dp: Dispatcher): db_user: User, db: AsyncSession ): + # permission gate for attachments view + 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 texts = get_texts(db_user.language) try: ticket_id = int(callback.data.replace("admin_ticket_attachments_", "")) diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 9822a144..96f9a8db 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -16,6 +16,7 @@ from app.services.subscription_checkout_service import ( should_offer_checkout_resume, ) from app.utils.photo_message import edit_or_answer_photo +from app.services.support_settings_service import SupportSettingsService logger = logging.getLogger(__name__) @@ -48,6 +49,7 @@ async def show_main_menu( keyboard=get_main_menu_keyboard( language=db_user.language, is_admin=settings.is_admin(db_user.telegram_id), + is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)), has_had_paid_subscription=db_user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, @@ -120,6 +122,7 @@ async def handle_back_to_menu( keyboard=get_main_menu_keyboard( language=db_user.language, is_admin=settings.is_admin(db_user.telegram_id), + is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)), has_had_paid_subscription=db_user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py index 2afc72e0..b421f426 100644 --- a/app/handlers/tickets.py +++ b/app/handlers/tickets.py @@ -374,12 +374,17 @@ async def show_my_tickets( except ValueError: current_page = 1 - # Получаем тикеты пользователя (открытые/закрытые отдельно) - all_tickets = await TicketCRUD.get_user_tickets(db, db_user.id, limit=100) - open_tickets = [t for t in all_tickets if t.status != TicketStatus.CLOSED.value] - closed_tickets = [t for t in all_tickets if t.status == TicketStatus.CLOSED.value] - - if not open_tickets and not closed_tickets: + # Пагинация открытых тикетов из БД + 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=[ @@ -400,32 +405,19 @@ async def show_my_tickets( await callback.answer() return - # Открытые с пагинацией - open_data = [] - for t in open_tickets: - if t.status != TicketStatus.CLOSED.value: - open_data.append({'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji}) - per_page = 10 - pag = get_pagination_info(total_count=len(open_data), page=current_page, per_page=per_page) - # Корректируем текущую страницу в допустимые границы - current_page = max(1, min(current_page, pag["total_pages"])) - start_index = (current_page - 1) * per_page - end_index = start_index + per_page - page_items = open_data[start_index:end_index] - keyboard = get_my_tickets_keyboard(page_items, current_page=current_page, total_pages=pag["total_pages"], language=db_user.language) + # Открытые с пагинацией (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")]) - # Покажем список тикетов c логотипом, если режим включен - if settings.ENABLE_LOGO_MODE and callback.message.photo: - from app.utils.photo_message import edit_or_answer_photo - await edit_or_answer_photo( - callback=callback, - caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), - keyboard=keyboard, - parse_mode="HTML", - ) - else: - await callback.message.edit_text(texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), reply_markup=keyboard) + # Всегда используем фото-рендер с логотипом (утилита сама сделает фоллбек при необходимости) + from app.utils.photo_message import edit_or_answer_photo + await edit_or_answer_photo( + callback=callback, + caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), + keyboard=keyboard, + parse_mode="HTML", + ) await callback.answer() @@ -435,9 +427,18 @@ async def show_my_tickets_closed( db: AsyncSession ): texts = get_texts(db_user.language) - # Пагинация (при необходимости можно добавить аналогично open) - tickets = await TicketCRUD.get_user_tickets(db, db_user.id, status=TicketStatus.CLOSED.value, limit=10) - if not tickets: + # Пагинация закрытых + 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=[ @@ -447,19 +448,20 @@ async def show_my_tickets_closed( ) 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=1, language=db_user.language) + 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")]) - if settings.ENABLE_LOGO_MODE and callback.message.photo: - from app.utils.photo_message import edit_or_answer_photo - await edit_or_answer_photo( - callback=callback, - caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), - keyboard=kb, - parse_mode="HTML", - ) - else: - await callback.message.edit_text(texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), reply_markup=kb) + from app.utils.photo_message import edit_or_answer_photo + await edit_or_answer_photo( + callback=callback, + caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), + keyboard=kb, + parse_mode="HTML", + ) await callback.answer() @@ -758,7 +760,10 @@ async def handle_ticket_reply( if ticket.status == TicketStatus.CLOSED.value: texts = get_texts(db_user.language) await message.answer( - texts.t("TICKET_CLOSED", "✅ Тикет закрыт.") + 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 @@ -767,7 +772,10 @@ async def handle_ticket_reply( 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", "❌ Тикет закрыт, ответить невозможно.") + 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 @@ -981,6 +989,10 @@ def register_handlers(dp: Dispatcher): 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, diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index c7a3a480..4137a918 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -10,6 +10,7 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")], [InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")], + [InlineKeyboardButton(text="🛟 Поддержка", callback_data="admin_submenu_support")], [InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")], [InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")], [InlineKeyboardButton(text="🛠️ Система", callback_data="admin_submenu_system")], @@ -61,15 +62,28 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey [ InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages") ], + [ + InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"), + InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + ] + ]) + + +def get_admin_support_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets") ], [ - InlineKeyboardButton(text="🛟 Настройки поддержки", callback_data="admin_support_settings") + InlineKeyboardButton(text="🧾 Аудит модераторов", callback_data="admin_support_audit") ], [ - InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"), - InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel") + InlineKeyboardButton(text="🛟 Настройки поддержки", callback_data="admin_support_settings") ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 033884f4..27400acb 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -67,6 +67,8 @@ def get_main_menu_keyboard( balance_kopeks: int = 0, subscription=None, show_resume_checkout: bool = False, + *, + is_moderator: bool = False, ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -198,6 +200,11 @@ def get_main_menu_keyboard( else: if settings.DEBUG: print("DEBUG KEYBOARD: Админ кнопка НЕ добавлена") + # Moderator access (limited support panel) + if (not is_admin) and is_moderator: + keyboard.append([ + InlineKeyboardButton(text="🧑‍⚖️ Модерация", callback_data="moderator_panel") + ]) return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -1535,7 +1542,8 @@ def get_my_tickets_keyboard( tickets: List[dict], current_page: int = 1, total_pages: int = 1, - language: str = DEFAULT_LANGUAGE + language: str = DEFAULT_LANGUAGE, + page_prefix: str = "my_tickets_page_" ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -1563,7 +1571,7 @@ def get_my_tickets_keyboard( nav_row.append( InlineKeyboardButton( text=texts.t("PAGINATION_PREV", "⬅️"), - callback_data=f"my_tickets_page_{current_page - 1}" + callback_data=f"{page_prefix}{current_page - 1}" ) ) @@ -1578,7 +1586,7 @@ def get_my_tickets_keyboard( nav_row.append( InlineKeyboardButton( text=texts.t("PAGINATION_NEXT", "➡️"), - callback_data=f"my_tickets_page_{current_page + 1}" + callback_data=f"{page_prefix}{current_page + 1}" ) ) @@ -1641,7 +1649,9 @@ def get_admin_tickets_keyboard( current_page: int = 1, total_pages: int = 1, language: str = DEFAULT_LANGUAGE, - scope: str = "all" + scope: str = "all", + *, + back_callback: str = "admin_submenu_support" ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -1706,7 +1716,7 @@ def get_admin_tickets_keyboard( keyboard.append(nav_row) keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications") + InlineKeyboardButton(text=texts.BACK, callback_data=back_callback) ]) return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -1715,7 +1725,9 @@ def get_admin_tickets_keyboard( def get_admin_ticket_view_keyboard( ticket_id: int, is_closed: bool = False, - language: str = DEFAULT_LANGUAGE + language: str = DEFAULT_LANGUAGE, + *, + is_user_blocked: bool = False ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -1736,14 +1748,16 @@ def get_admin_ticket_view_keyboard( ) ]) - # Block controls: first row Unblock + Block forever, second row Block by time - keyboard.append([ - InlineKeyboardButton(text=texts.t("UNBLOCK", "✅ Разблокировать"), callback_data=f"admin_unblock_user_ticket_{ticket_id}"), - InlineKeyboardButton(text=texts.t("BLOCK_FOREVER", "🚫 Блок навсегда"), callback_data=f"admin_block_user_perm_ticket_{ticket_id}") - ]) - keyboard.append([ - InlineKeyboardButton(text=texts.t("BLOCK_BY_TIME", "⏳ Блокировка по времени"), callback_data=f"admin_block_user_ticket_{ticket_id}") - ]) + # Блок-контролы: когда не заблокирован — показать два варианта, когда заблокирован — только "Разблокировать" + if is_user_blocked: + keyboard.append([ + InlineKeyboardButton(text=texts.t("UNBLOCK", "✅ Разблокировать"), callback_data=f"admin_unblock_user_ticket_{ticket_id}") + ]) + else: + keyboard.append([ + InlineKeyboardButton(text=texts.t("BLOCK_FOREVER", "🚫 Заблокировать"), callback_data=f"admin_block_user_perm_ticket_{ticket_id}"), + InlineKeyboardButton(text=texts.t("BLOCK_BY_TIME", "⏳ Блок по времени"), callback_data=f"admin_block_user_ticket_{ticket_id}") + ]) keyboard.append([ InlineKeyboardButton(text=texts.BACK, callback_data="admin_tickets") diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 6995e761..bd81c8fa 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -401,4 +401,5 @@ "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance." + } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 0fbb106e..ac0e159f 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -55,6 +55,8 @@ "ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.", "ADMIN_SUBSCRIPTIONS": "📱 Подписки", "ADMIN_USERS": "👥 Пользователи", + "ADMIN_TICKETS_TITLE_OPEN": "🎫 Открытые тикеты поддержки:", + "ADMIN_TICKETS_TITLE_CLOSED": "🎫 Закрытые тикеты поддержки:", "AUTOPAY_BUTTON": "💳 Автоплатёж", "AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!", "AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически", @@ -401,4 +403,5 @@ "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку." + } diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 24deb059..040a2d27 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -818,7 +818,13 @@ class AdminNotificationService: """Публичный метод для отправки уведомлений по тикетам в админ-топик. Учитывает настройки включенности в settings. """ - if not self._is_enabled(): + # Respect runtime toggle for admin ticket notifications + try: + from app.services.support_settings_service import SupportSettingsService + runtime_enabled = SupportSettingsService.get_admin_ticket_notifications_enabled() + except Exception: + runtime_enabled = True + if not (self._is_enabled() and runtime_enabled): return False return await self._send_message(text, reply_markup=keyboard, ticket_event=True) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 2d0b96c7..a190aec4 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -3,7 +3,7 @@ import logging from datetime import datetime, timedelta from typing import Dict, List, Any, Optional, Set from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_ +from sqlalchemy import select, and_, or_ from sqlalchemy.orm import selectinload from app.config import settings @@ -21,7 +21,7 @@ from app.database.crud.notification import ( notification_sent, record_notification, ) -from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User +from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus from app.services.subscription_service import SubscriptionService from app.services.payment_service import PaymentService from app.localization.texts import get_texts @@ -42,6 +42,7 @@ class MonitoringService: self.bot = bot self._notified_users: Set[str] = set() self._last_cleanup = datetime.utcnow() + self._sla_task = None async def start_monitoring(self): if self.is_running: @@ -50,6 +51,12 @@ class MonitoringService: self.is_running = True logger.info("🔄 Запуск службы мониторинга") + # Start dedicated SLA loop with its own interval for timely 5-min checks + try: + if not self._sla_task or self._sla_task.done(): + self._sla_task = asyncio.create_task(self._sla_loop()) + except Exception as e: + logger.error(f"Не удалось запустить SLA-мониторинг: {e}") while self.is_running: try: @@ -63,6 +70,11 @@ class MonitoringService: def stop_monitoring(self): self.is_running = False logger.info("ℹ️ Мониторинг остановлен") + try: + if self._sla_task and not self._sla_task.done(): + self._sla_task.cancel() + except Exception: + pass async def _monitoring_cycle(self): async for db in get_db(): @@ -576,6 +588,107 @@ class MonitoringService: is_success=False ) + async def _check_ticket_sla(self, db: AsyncSession): + try: + # Quick guards + # Allow runtime toggle from SupportSettingsService + try: + from app.services.support_settings_service import SupportSettingsService + sla_enabled_runtime = SupportSettingsService.get_sla_enabled() + except Exception: + sla_enabled_runtime = getattr(settings, 'SUPPORT_TICKET_SLA_ENABLED', True) + if not sla_enabled_runtime: + return + if not self.bot: + return + if not settings.is_admin_notifications_enabled(): + return + + from datetime import datetime, timedelta + try: + from app.services.support_settings_service import SupportSettingsService + sla_minutes = max(1, int(SupportSettingsService.get_sla_minutes())) + except Exception: + sla_minutes = max(1, int(getattr(settings, 'SUPPORT_TICKET_SLA_MINUTES', 5))) + cooldown_minutes = max(1, int(getattr(settings, 'SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES', 15))) + now = datetime.utcnow() + stale_before = now - timedelta(minutes=sla_minutes) + cooldown_before = now - timedelta(minutes=cooldown_minutes) + + # Tickets to remind: open, no admin reply yet after user's last message (status OPEN), stale by SLA, + # and either never reminded or cooldown passed + result = await db.execute( + select(Ticket) + .options(selectinload(Ticket.user)) + .where( + and_( + Ticket.status == TicketStatus.OPEN.value, + Ticket.updated_at <= stale_before, + or_(Ticket.last_sla_reminder_at.is_(None), Ticket.last_sla_reminder_at <= cooldown_before), + ) + ) + ) + tickets = result.scalars().all() + if not tickets: + return + + from app.services.admin_notification_service import AdminNotificationService + + reminders_sent = 0 + service = AdminNotificationService(self.bot) + + for ticket in tickets: + try: + waited_minutes = max(0, int((now - ticket.updated_at).total_seconds() // 60)) + title = (ticket.title or '').strip() + if len(title) > 60: + title = title[:57] + '...' + + text = ( + f"⏰ Ожидание ответа на тикет превышено\n\n" + f"🆔 ID: {ticket.id}\n" + f"👤 User ID: {ticket.user_id}\n" + f"📝 Заголовок: {title or '—'}\n" + f"⏱️ Ожидает ответа: {waited_minutes} мин\n" + ) + + sent = await service.send_ticket_event_notification(text) + if sent: + ticket.last_sla_reminder_at = now + reminders_sent += 1 + # commit after each to persist timestamp and avoid duplicate reminders on crash + await db.commit() + except Exception as notify_error: + logger.error(f"Ошибка отправки SLA-уведомления по тикету {ticket.id}: {notify_error}") + + if reminders_sent > 0: + await self._log_monitoring_event( + db, + "ticket_sla_reminders_sent", + f"Отправлено {reminders_sent} SLA-напоминаний по тикетам", + {"count": reminders_sent}, + ) + except Exception as e: + logger.error(f"Ошибка проверки SLA тикетов: {e}") + + async def _sla_loop(self): + try: + interval_seconds = max(10, int(getattr(settings, 'SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS', 60))) + except Exception: + interval_seconds = 60 + while self.is_running: + try: + async for db in get_db(): + try: + await self._check_ticket_sla(db) + finally: + break + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Ошибка в SLA-цикле: {e}") + await asyncio.sleep(interval_seconds) + async def _log_monitoring_event( self, db: AsyncSession, diff --git a/app/services/support_settings_service.py b/app/services/support_settings_service.py index 19be0b10..7943b84a 100644 --- a/app/services/support_settings_service.py +++ b/app/services/support_settings_service.py @@ -110,3 +110,112 @@ class SupportSettingsService: return cls._save() + # Notifications & SLA + @classmethod + def get_admin_ticket_notifications_enabled(cls) -> bool: + cls._load() + if "admin_ticket_notifications_enabled" in cls._data: + return bool(cls._data["admin_ticket_notifications_enabled"]) + # fallback to global admin notifications setting + return bool(settings.is_admin_notifications_enabled()) + + @classmethod + def set_admin_ticket_notifications_enabled(cls, enabled: bool) -> bool: + cls._load() + cls._data["admin_ticket_notifications_enabled"] = bool(enabled) + return cls._save() + + @classmethod + def get_user_ticket_notifications_enabled(cls) -> bool: + cls._load() + if "user_ticket_notifications_enabled" in cls._data: + return bool(cls._data["user_ticket_notifications_enabled"]) + # fallback to global enable notifications + return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True)) + + @classmethod + def set_user_ticket_notifications_enabled(cls, enabled: bool) -> bool: + cls._load() + cls._data["user_ticket_notifications_enabled"] = bool(enabled) + return cls._save() + + @classmethod + def get_sla_enabled(cls) -> bool: + cls._load() + if "ticket_sla_enabled" in cls._data: + return bool(cls._data["ticket_sla_enabled"]) + return bool(getattr(settings, "SUPPORT_TICKET_SLA_ENABLED", True)) + + @classmethod + def set_sla_enabled(cls, enabled: bool) -> bool: + cls._load() + cls._data["ticket_sla_enabled"] = bool(enabled) + return cls._save() + + @classmethod + def get_sla_minutes(cls) -> int: + cls._load() + minutes = cls._data.get("ticket_sla_minutes") + if isinstance(minutes, int) and minutes > 0: + return minutes + return int(getattr(settings, "SUPPORT_TICKET_SLA_MINUTES", 5)) + + @classmethod + def set_sla_minutes(cls, minutes: int) -> bool: + try: + minutes_int = int(minutes) + except Exception: + return False + if minutes_int <= 0: + return False + cls._load() + cls._data["ticket_sla_minutes"] = minutes_int + return cls._save() + + # Moderators management + @classmethod + def get_moderators(cls) -> list[int]: + cls._load() + raw = cls._data.get("moderators") or [] + moderators: list[int] = [] + for item in raw: + try: + moderators.append(int(item)) + except Exception: + continue + return moderators + + @classmethod + def is_moderator(cls, telegram_id: int) -> bool: + try: + tid = int(telegram_id) + except Exception: + return False + return tid in cls.get_moderators() + + @classmethod + def add_moderator(cls, telegram_id: int) -> bool: + try: + tid = int(telegram_id) + except Exception: + return False + cls._load() + moderators = set(cls.get_moderators()) + moderators.add(tid) + cls._data["moderators"] = sorted(moderators) + return cls._save() + + @classmethod + def remove_moderator(cls, telegram_id: int) -> bool: + try: + tid = int(telegram_id) + except Exception: + return False + cls._load() + moderators = set(cls.get_moderators()) + if tid in moderators: + moderators.remove(tid) + cls._data["moderators"] = sorted(moderators) + return cls._save() + return True + diff --git a/locales/ru.json b/locales/ru.json index 328c0d33..54cbba08 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -30,7 +30,7 @@ "TICKET_TITLE_INPUT": "Введите заголовок тикета:", "TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:", "TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:", - "TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото без текста:", + "TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото c подписью:", "TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:", "TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.", "VIEW_TICKET": "👁️ Посмотреть тикет", @@ -68,7 +68,7 @@ "CLOSE_NOTIFICATION": "❌ Закрыть уведомление", "NOTIFICATION_CLOSED": "Уведомление закрыто.", "UNBLOCK": "✅ Разблокировать", - "BLOCK_FOREVER": "🚫 Блок навсегда", + "BLOCK_FOREVER": "🚫 Заблокировать", "BLOCK_BY_TIME": "⏳ Блокировка по времени", "TICKET_ATTACHMENTS": "📎 Вложения", "OPEN_TICKETS": "🔴 Открытые", From 3105b811d44413481922556db3b10d1e4d1c1ef1 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 22:54:33 +0300 Subject: [PATCH 003/146] Show checkout resume button for expired subscriptions --- app/services/subscription_checkout_service.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/services/subscription_checkout_service.py b/app/services/subscription_checkout_service.py index e49b6a5e..94b5e85e 100644 --- a/app/services/subscription_checkout_service.py +++ b/app/services/subscription_checkout_service.py @@ -49,4 +49,10 @@ def should_offer_checkout_resume(user: User, has_draft: bool) -> bool: if subscription is None: return True - return bool(getattr(subscription, "is_trial", False)) + if getattr(subscription, "is_trial", False): + return True + + if getattr(subscription, "actual_status", None) == "expired": + return True + + return False From 19fe40b1727b5408b7dc57f7d1c35bf9ed0d0fe6 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 23:16:51 +0300 Subject: [PATCH 004/146] Remove subscription button from post-purchase prompts --- app/handlers/subscription.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 3c54f0a7..9e924020 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -801,7 +801,6 @@ async def activate_trial( web_app=types.WebAppInfo(url=subscription.subscription_url), ) ], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) elif connect_mode == "miniapp_custom": @@ -822,19 +821,16 @@ async def activate_trial( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), ) ], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) elif connect_mode == "link": connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) @@ -3005,7 +3001,6 @@ async def confirm_purchase( web_app=types.WebAppInfo(url=subscription.subscription_url), ) ], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) elif connect_mode == "miniapp_custom": @@ -3026,19 +3021,16 @@ async def confirm_purchase( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), ) ], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) elif connect_mode == "link": connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) From c4802fe44254e874c8e2ea1a56fccc0a8ac921ea Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 23:19:13 +0300 Subject: [PATCH 005/146] Fix top-up status detection in admin notifications --- app/services/admin_notification_service.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 040a2d27..d9a3711a 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -3,10 +3,11 @@ from typing import Optional, Dict, Any, List from datetime import datetime from aiogram import Bot, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import User, Subscription, Transaction +from app.database.models import User, Subscription, Transaction, TransactionType from app.database.crud.user import get_user_by_id logger = logging.getLogger(__name__) @@ -215,7 +216,17 @@ class AdminNotificationService: return False try: - topup_status = "🆕 Первое пополнение" if not user.has_made_first_topup else "🔄 Пополнение" + deposit_count_result = await db.execute( + select(func.count()) + .select_from(Transaction) + .where( + Transaction.user_id == user.id, + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed.is_(True) + ) + ) + deposit_count = deposit_count_result.scalar_one() or 0 + topup_status = "🆕 Первое пополнение" if deposit_count <= 1 else "🔄 Пополнение" payment_method = self._get_payment_method_display(transaction.payment_method) balance_change = user.balance_kopeks - old_balance referrer_info = await self._get_referrer_info(db, user.referred_by_id) From bb5af6665657a463018b41d2ee5f52500387831b Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 23:24:24 +0300 Subject: [PATCH 006/146] Fix duplicate import and referral code generation --- app/database/crud/user.py | 3 +-- app/utils/pricing_utils.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 1abb0055..582c8695 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -88,8 +88,7 @@ async def create_user( ) -> User: if not referral_code: - from app.utils.user_utils import generate_unique_referral_code - referral_code = await generate_unique_referral_code(db, telegram_id) + referral_code = await create_unique_referral_code(db) default_group = await get_default_promo_group(db) if not default_group: diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index f6ca0d5a..40d7f589 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,5 +1,4 @@ from datetime import datetime, timedelta -from datetime import datetime from typing import Tuple import logging From 1451bfac12c5ae7b690015e3ce6cfd9a0ef4cb8b Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 23:40:17 +0300 Subject: [PATCH 007/146] Fix Telegram Stars payments handler priority --- app/handlers/common.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/handlers/common.py b/app/handlers/common.py index 05ecb178..fab936a4 100644 --- a/app/handlers/common.py +++ b/app/handlers/common.py @@ -120,5 +120,10 @@ def register_handlers(dp: Dispatcher): ) # Самый последний: ловим любые неизвестные текстовые сообщения - dp.message.register(handle_unknown_message) + # Исключаем специальные сервисные события (например, успешные платежи), + # чтобы их обработка не прерывалась общим хендлером неизвестных сообщений + dp.message.register( + handle_unknown_message, + F.successful_payment.is_(None) + ) \ No newline at end of file From eb730ecd6ebf6ba941ce7b1eac0133f5479be5a2 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 01:14:15 +0300 Subject: [PATCH 008/146] Add Mulen Pay integration for balance top-ups --- app/config.py | 24 +- app/database/crud/mulenpay.py | 118 +++++++ app/database/models.py | 50 ++- app/database/universal_migration.py | 109 +++++++ app/external/webhook_server.py | 52 ++- app/handlers/balance.py | 240 +++++++++++++- app/keyboards/inline.py | 12 +- app/services/admin_notification_service.py | 1 + app/services/mulenpay_service.py | 126 ++++++++ app/services/payment_service.py | 354 ++++++++++++++++++++- app/utils/payment_utils.py | 22 +- locales/en.json | 7 + locales/ru.json | 7 + main.py | 10 +- 14 files changed, 1112 insertions(+), 20 deletions(-) create mode 100644 app/database/crud/mulenpay.py create mode 100644 app/services/mulenpay_service.py diff --git a/app/config.py b/app/config.py index e0075783..1824490e 100644 --- a/app/config.py +++ b/app/config.py @@ -174,6 +174,18 @@ class Settings(BaseSettings): CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH" CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24 + MULENPAY_ENABLED: bool = False + MULENPAY_API_KEY: Optional[str] = None + MULENPAY_SECRET_KEY: Optional[str] = None + MULENPAY_SHOP_ID: Optional[int] = None + MULENPAY_BASE_URL: str = "https://mulenpay.ru/api" + MULENPAY_WEBHOOK_PATH: str = "/mulenpay-webhook" + MULENPAY_DESCRIPTION: str = "Пополнение баланса" + MULENPAY_LANGUAGE: str = "ru" + MULENPAY_VAT_CODE: int = 0 + MULENPAY_PAYMENT_SUBJECT: int = 4 + MULENPAY_PAYMENT_MODE: int = 4 + CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" HIDE_SUBSCRIPTION_LINK: bool = False @@ -440,9 +452,17 @@ class Settings(BaseSettings): return "https://t.me/" def is_cryptobot_enabled(self) -> bool: - return (self.CRYPTOBOT_ENABLED and + return (self.CRYPTOBOT_ENABLED and self.CRYPTOBOT_API_TOKEN is not None) - + + def is_mulenpay_enabled(self) -> bool: + return ( + self.MULENPAY_ENABLED + and self.MULENPAY_API_KEY is not None + and self.MULENPAY_SECRET_KEY is not None + and self.MULENPAY_SHOP_ID is not None + ) + def get_cryptobot_base_url(self) -> str: if self.CRYPTOBOT_TESTNET: return "https://testnet-pay.crypt.bot" diff --git a/app/database/crud/mulenpay.py b/app/database/crud/mulenpay.py new file mode 100644 index 00000000..245849da --- /dev/null +++ b/app/database/crud/mulenpay.py @@ -0,0 +1,118 @@ +import logging +from datetime import datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import MulenPayPayment + +logger = logging.getLogger(__name__) + + +async def create_mulenpay_payment( + db: AsyncSession, + *, + user_id: int, + amount_kopeks: int, + uuid: str, + description: str, + payment_url: Optional[str], + mulen_payment_id: Optional[int], + currency: str, + status: str, + metadata: Optional[dict] = None, +) -> MulenPayPayment: + payment = MulenPayPayment( + user_id=user_id, + amount_kopeks=amount_kopeks, + uuid=uuid, + description=description, + payment_url=payment_url, + mulen_payment_id=mulen_payment_id, + currency=currency, + status=status, + metadata_json=metadata or {}, + ) + + db.add(payment) + await db.commit() + await db.refresh(payment) + + logger.info( + "Создан MulenPay платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s", + payment.mulen_payment_id, + uuid, + amount_kopeks, + user_id, + ) + + return payment + + +async def get_mulenpay_payment_by_local_id( + db: AsyncSession, payment_id: int +) -> Optional[MulenPayPayment]: + result = await db.execute( + select(MulenPayPayment).where(MulenPayPayment.id == payment_id) + ) + return result.scalar_one_or_none() + + +async def get_mulenpay_payment_by_uuid( + db: AsyncSession, uuid: str +) -> Optional[MulenPayPayment]: + result = await db.execute( + select(MulenPayPayment).where(MulenPayPayment.uuid == uuid) + ) + return result.scalar_one_or_none() + + +async def get_mulenpay_payment_by_mulen_id( + db: AsyncSession, mulen_payment_id: int +) -> Optional[MulenPayPayment]: + result = await db.execute( + select(MulenPayPayment).where( + MulenPayPayment.mulen_payment_id == mulen_payment_id + ) + ) + return result.scalar_one_or_none() + + +async def update_mulenpay_payment_status( + db: AsyncSession, + *, + payment: MulenPayPayment, + status: str, + is_paid: Optional[bool] = None, + paid_at: Optional[datetime] = None, + callback_payload: Optional[dict] = None, + mulen_payment_id: Optional[int] = None, +) -> MulenPayPayment: + payment.status = status + if is_paid is not None: + payment.is_paid = is_paid + if paid_at: + payment.paid_at = paid_at + if callback_payload is not None: + payment.callback_payload = callback_payload + if mulen_payment_id is not None and not payment.mulen_payment_id: + payment.mulen_payment_id = mulen_payment_id + + payment.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(payment) + return payment + + +async def link_mulenpay_payment_to_transaction( + db: AsyncSession, + *, + payment: MulenPayPayment, + transaction_id: int, +) -> MulenPayPayment: + payment.transaction_id = transaction_id + payment.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(payment) + return payment diff --git a/app/database/models.py b/app/database/models.py index b1e3014d..3db2df8a 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -53,9 +53,10 @@ class PromoCodeType(Enum): class PaymentMethod(Enum): TELEGRAM_STARS = "telegram_stars" TRIBUTE = "tribute" - YOOKASSA = "yookassa" + YOOKASSA = "yookassa" CRYPTOBOT = "cryptobot" - MANUAL = "manual" + MULENPAY = "mulenpay" + MANUAL = "manual" class YooKassaPayment(Base): __tablename__ = "yookassa_payments" @@ -107,7 +108,7 @@ class YooKassaPayment(Base): class CryptoBotPayment(Base): __tablename__ = "cryptobot_payments" - + id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) @@ -155,6 +156,49 @@ class CryptoBotPayment(Base): return f"" +class MulenPayPayment(Base): + __tablename__ = "mulenpay_payments" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + mulen_payment_id = Column(Integer, nullable=True, index=True) + uuid = Column(String(255), unique=True, nullable=False, index=True) + amount_kopeks = Column(Integer, nullable=False) + currency = Column(String(10), nullable=False, default="RUB") + description = Column(Text, nullable=True) + + status = Column(String(50), nullable=False, default="created") + is_paid = Column(Boolean, default=False) + paid_at = Column(DateTime, nullable=True) + + payment_url = Column(Text, nullable=True) + metadata_json = Column(JSON, nullable=True) + callback_payload = Column(JSON, nullable=True) + + transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + user = relationship("User", backref="mulenpay_payments") + transaction = relationship("Transaction", backref="mulenpay_payment") + + @property + def amount_rubles(self) -> float: + return self.amount_kopeks / 100 + + def __repr__(self) -> str: # pragma: no cover - debug helper + return ( + "".format( + self.id, + self.mulen_payment_id, + self.amount_rubles, + self.status, + ) + ) + + class PromoGroup(Base): __tablename__ = "promo_groups" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 36c88c45..c122b86c 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -274,6 +274,108 @@ async def create_cryptobot_payments_table(): logger.error(f"Ошибка создания таблицы cryptobot_payments: {e}") return False + +async def create_mulenpay_payments_table(): + table_exists = await check_table_exists('mulenpay_payments') + if table_exists: + logger.info("Таблица mulenpay_payments уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + create_sql = """ + CREATE TABLE mulenpay_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + mulen_payment_id INTEGER NULL, + uuid VARCHAR(255) NOT NULL UNIQUE, + amount_kopeks INTEGER NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'RUB', + description TEXT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'created', + is_paid BOOLEAN DEFAULT 0, + paid_at DATETIME NULL, + payment_url TEXT NULL, + metadata_json JSON NULL, + callback_payload JSON NULL, + transaction_id INTEGER NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (transaction_id) REFERENCES transactions(id) + ); + + CREATE INDEX idx_mulenpay_uuid ON mulenpay_payments(uuid); + CREATE INDEX idx_mulenpay_payment_id ON mulenpay_payments(mulen_payment_id); + """ + + elif db_type == 'postgresql': + create_sql = """ + CREATE TABLE mulenpay_payments ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + mulen_payment_id INTEGER NULL, + uuid VARCHAR(255) NOT NULL UNIQUE, + amount_kopeks INTEGER NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'RUB', + description TEXT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'created', + is_paid BOOLEAN NOT NULL DEFAULT FALSE, + paid_at TIMESTAMP NULL, + payment_url TEXT NULL, + metadata_json JSON NULL, + callback_payload JSON NULL, + transaction_id INTEGER NULL REFERENCES transactions(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX idx_mulenpay_uuid ON mulenpay_payments(uuid); + CREATE INDEX idx_mulenpay_payment_id ON mulenpay_payments(mulen_payment_id); + """ + + elif db_type == 'mysql': + create_sql = """ + CREATE TABLE mulenpay_payments ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + mulen_payment_id INT NULL, + uuid VARCHAR(255) NOT NULL UNIQUE, + amount_kopeks INT NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'RUB', + description TEXT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'created', + is_paid BOOLEAN NOT NULL DEFAULT 0, + paid_at DATETIME NULL, + payment_url TEXT NULL, + metadata_json JSON NULL, + callback_payload JSON NULL, + transaction_id INT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (transaction_id) REFERENCES transactions(id) + ); + + CREATE INDEX idx_mulenpay_uuid ON mulenpay_payments(uuid); + CREATE INDEX idx_mulenpay_payment_id ON mulenpay_payments(mulen_payment_id); + """ + + else: + logger.error(f"Неподдерживаемый тип БД для таблицы mulenpay_payments: {db_type}") + return False + + await conn.execute(text(create_sql)) + logger.info("Таблица mulenpay_payments успешно создана") + return True + + except Exception as e: + logger.error(f"Ошибка создания таблицы mulenpay_payments: {e}") + return False + async def create_user_messages_table(): table_exists = await check_table_exists('user_messages') if table_exists: @@ -1103,6 +1205,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей CryptoBot payments") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MULEN PAY ===") + mulenpay_created = await create_mulenpay_payments_table() + if mulenpay_created: + logger.info("✅ Таблица Mulen Pay payments готова") + else: + logger.warning("⚠️ Проблемы с таблицей Mulen Pay payments") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===") user_messages_created = await create_user_messages_table() if user_messages_created: diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index 0abe9166..0a2d0003 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -7,6 +7,8 @@ from aiogram import Bot from app.config import settings from app.services.tribute_service import TributeService +from app.services.payment_service import PaymentService +from app.database.database import get_db logger = logging.getLogger(__name__) @@ -25,18 +27,25 @@ class WebhookServer: self.app = web.Application() self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler) - + + if settings.is_mulenpay_enabled(): + self.app.router.add_post(settings.MULENPAY_WEBHOOK_PATH, self._mulenpay_webhook_handler) + if settings.is_cryptobot_enabled(): self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler) self.app.router.add_get('/health', self._health_check) self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler) + if settings.is_mulenpay_enabled(): + self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler) if settings.is_cryptobot_enabled(): self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler) logger.info(f"Webhook сервер настроен:") logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}") + if settings.is_mulenpay_enabled(): + logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}") if settings.is_cryptobot_enabled(): logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}") logger.info(f" - Health check: GET /health") @@ -62,6 +71,10 @@ class WebhookServer: logger.info(f"Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}") logger.info(f"Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}") + if settings.is_mulenpay_enabled(): + logger.info( + f"Mulen Pay webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}" + ) if settings.is_cryptobot_enabled(): logger.info(f"CryptoBot webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}") @@ -92,9 +105,42 @@ class WebhookServer: 'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature', } ) - + + async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response: + try: + logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}") + raw_body = await request.read() + + if not raw_body: + logger.warning("Пустой Mulen Pay webhook") + return web.json_response({"status": "error", "reason": "empty_body"}, status=400) + + try: + payload = json.loads(raw_body.decode('utf-8')) + except json.JSONDecodeError as error: + logger.error(f"Ошибка парсинга Mulen Pay webhook: {error}") + return web.json_response({"status": "error", "reason": "invalid_json"}, status=400) + + payment_service = PaymentService(self.bot) + + async for db in get_db(): + try: + success = await payment_service.process_mulenpay_callback(db, payload) + if success: + return web.json_response({"status": "ok"}, status=200) + return web.json_response({"status": "error", "reason": "processing_failed"}, status=400) + except Exception as error: + logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True) + return web.json_response({"status": "error", "reason": "internal_error"}, status=500) + finally: + break + + except Exception as error: + logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True) + return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500) + async def _tribute_webhook_handler(self, request: web.Request) -> web.Response: - + try: logger.info(f"Получен Tribute webhook: {request.method} {request.path}") logger.info(f"Headers: {dict(request.headers)}") diff --git a/app/handlers/balance.py b/app/handlers/balance.py index 870b9649..dc875340 100644 --- a/app/handlers/balance.py +++ b/app/handlers/balance.py @@ -332,6 +332,45 @@ async def start_yookassa_sbp_payment( await callback.answer() +@error_handler +async def start_mulenpay_payment( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + texts = get_texts(db_user.language) + + if not settings.is_mulenpay_enabled(): + await callback.answer("❌ Оплата через Mulen Pay временно недоступна", show_alert=True) + return + + message_text = texts.t( + "MULENPAY_TOPUP_PROMPT", + ( + "💳 Оплата через Mulen Pay\n\n" + "Введите сумму для пополнения от 100 до 100 000 ₽.\n" + "Оплата происходит через защищенную платформу Mulen Pay." + ), + ) + + keyboard = get_back_keyboard(db_user.language) + + if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: + quick_amount_buttons = get_quick_amount_buttons(db_user.language) + if quick_amount_buttons: + keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard + + await callback.message.edit_text( + message_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + + await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data(payment_method="mulenpay") + await callback.answer() + + @error_handler async def start_tribute_payment( callback: types.CallbackQuery, @@ -523,10 +562,14 @@ async def process_topup_amount( from app.database.database import AsyncSessionLocal async with AsyncSessionLocal() as db: await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state) - elif payment_method == "yookassa_sbp": + elif payment_method == "yookassa_sbp": from app.database.database import AsyncSessionLocal async with AsyncSessionLocal() as db: await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state) + elif payment_method == "mulenpay": + from app.database.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state) elif payment_method == "cryptobot": from app.database.database import AsyncSessionLocal async with AsyncSessionLocal() as db: @@ -760,6 +803,124 @@ async def process_yookassa_sbp_payment_amount( await state.clear() +@error_handler +async def process_mulenpay_payment_amount( + message: types.Message, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + state: FSMContext, +): + texts = get_texts(db_user.language) + + if not settings.is_mulenpay_enabled(): + await message.answer("❌ Оплата через Mulen Pay временно недоступна") + return + + amount_rubles = amount_kopeks / 100 + + if amount_rubles < 100: + await message.answer("Минимальная сумма пополнения: 100 ₽") + return + + if amount_rubles > 100000: + await message.answer("Максимальная сумма пополнения: 100,000 ₽") + return + + try: + payment_service = PaymentService(message.bot) + payment_result = await payment_service.create_mulenpay_payment( + db=db, + user_id=db_user.id, + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + language=db_user.language, + ) + + if not payment_result or not payment_result.get("payment_url"): + await message.answer( + texts.t( + "MULENPAY_PAYMENT_ERROR", + "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.", + ) + ) + await state.clear() + return + + payment_url = payment_result.get("payment_url") + mulen_payment_id = payment_result.get("mulen_payment_id") + local_payment_id = payment_result.get("local_payment_id") + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "MULENPAY_PAY_BUTTON", + "💳 Оплатить через Mulen Pay", + ), + url=payment_url, + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), + callback_data=f"check_mulenpay_{local_payment_id}", + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], + ] + ) + + payment_id_display = mulen_payment_id if mulen_payment_id is not None else local_payment_id + + message_template = texts.t( + "MULENPAY_PAYMENT_INSTRUCTIONS", + ( + "💳 Оплата через Mulen Pay\n\n" + "💰 Сумма: {amount}\n" + "🆔 ID платежа: {payment_id}\n\n" + "📱 Инструкция:\n" + "1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n" + "2. Следуйте подсказкам платежной системы\n" + "3. Подтвердите перевод\n" + "4. Средства зачислятся автоматически\n\n" + "❓ Если возникнут проблемы, обратитесь в {support}" + ), + ) + + message_text = message_template.format( + amount=settings.format_price(amount_kopeks), + payment_id=payment_id_display, + support=settings.get_support_contact_display_html(), + ) + + await message.answer( + message_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + + await state.clear() + + logger.info( + "Создан MulenPay платеж для пользователя %s: %s₽, ID: %s", + db_user.telegram_id, + amount_rubles, + payment_id_display, + ) + + except Exception as e: + logger.error(f"Ошибка создания MulenPay платежа: {e}") + await message.answer( + texts.t( + "MULENPAY_PAYMENT_ERROR", + "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.", + ) + ) + await state.clear() + + @error_handler async def check_yookassa_payment_status( callback: types.CallbackQuery, @@ -815,6 +976,63 @@ async def check_yookassa_payment_status( logger.error(f"Ошибка проверки статуса платежа: {e}") await callback.answer("❌ Ошибка проверки статуса", show_alert=True) + +@error_handler +async def check_mulenpay_payment_status( + callback: types.CallbackQuery, + db: AsyncSession +): + try: + local_payment_id = int(callback.data.split('_')[-1]) + payment_service = PaymentService(callback.bot) + status_info = await payment_service.get_mulenpay_payment_status(db, local_payment_id) + + if not status_info: + await callback.answer("❌ Платеж не найден", show_alert=True) + return + + payment = status_info["payment"] + + status_labels = { + "created": ("⏳", "Ожидает оплаты"), + "processing": ("⌛", "Обрабатывается"), + "success": ("✅", "Оплачен"), + "canceled": ("❌", "Отменен"), + "error": ("⚠️", "Ошибка"), + "hold": ("🔒", "Холд"), + "unknown": ("❓", "Неизвестно"), + } + + emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно")) + + message_lines = [ + "💳 Статус платежа Mulen Pay:\n\n", + f"🆔 ID: {payment.mulen_payment_id or payment.id}\n", + f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n", + f"📊 Статус: {emoji} {status_text}\n", + f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n", + ] + + if payment.is_paid: + message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.") + elif payment.status in {"created", "processing"}: + message_lines.append( + "\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже." + ) + if payment.payment_url: + message_lines.append(f"\n🔗 Ссылка на оплату: {payment.payment_url}") + elif payment.status in {"canceled", "error"}: + message_lines.append( + f"\n❌ Платеж не был завершен. Попробуйте создать новый платеж или обратитесь в {settings.get_support_contact_display()}" + ) + + await callback.answer("".join(message_lines), show_alert=True) + + except Exception as e: + logger.error(f"Ошибка проверки статуса MulenPay: {e}") + await callback.answer("❌ Ошибка проверки статуса", show_alert=True) + + @error_handler async def start_cryptobot_payment( callback: types.CallbackQuery, @@ -1085,6 +1303,12 @@ async def handle_quick_amount_selection( await process_yookassa_sbp_payment_amount( callback.message, db_user, db, amount_kopeks, state ) + elif payment_method == "mulenpay": + from app.database.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + await process_mulenpay_payment_amount( + callback.message, db_user, db, amount_kopeks, state + ) else: await callback.answer("❌ Неизвестный способ оплаты", show_alert=True) return @@ -1132,12 +1356,17 @@ def register_handlers(dp: Dispatcher): start_yookassa_sbp_payment, F.data == "topup_yookassa_sbp" ) - + + dp.callback_query.register( + start_mulenpay_payment, + F.data == "topup_mulenpay" + ) + dp.callback_query.register( check_yookassa_payment_status, F.data.startswith("check_yookassa_") ) - + dp.callback_query.register( start_tribute_payment, F.data == "topup_tribute" @@ -1168,6 +1397,11 @@ def register_handlers(dp: Dispatcher): F.data.startswith("check_cryptobot_") ) + dp.callback_query.register( + check_mulenpay_payment_status, + F.data.startswith("check_mulenpay_") + ) + dp.callback_query.register( handle_payment_methods_unavailable, F.data == "payment_methods_unavailable" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index afc8c61e..7802b31e 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -644,15 +644,23 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN if settings.TRIBUTE_ENABLED: keyboard.append([ InlineKeyboardButton( - text=texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)"), + text=texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)"), callback_data="topup_tribute" ) ]) + if settings.is_mulenpay_enabled(): + keyboard.append([ + InlineKeyboardButton( + text=texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)"), + callback_data="topup_mulenpay" + ) + ]) + if settings.is_cryptobot_enabled(): keyboard.append([ InlineKeyboardButton( - text=texts.t("PAYMENT_CRYPTOBOT", "🪙 Криптовалюта (CryptoBot)"), + text=texts.t("PAYMENT_CRYPTOBOT", "🪙 Криптовалюта (CryptoBot)"), callback_data="topup_cryptobot" ) ]) diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index d9a3711a..59831d1e 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -352,6 +352,7 @@ class AdminNotificationService: 'telegram_stars': '⭐ Telegram Stars', 'yookassa': '💳 YooKassa (карта)', 'tribute': '💎 Tribute (карта)', + 'mulenpay': '💳 Mulen Pay (карта)', 'manual': '🛠️ Вручную (админ)', 'balance': '💰 С баланса' } diff --git a/app/services/mulenpay_service.py b/app/services/mulenpay_service.py new file mode 100644 index 00000000..afcb44d9 --- /dev/null +++ b/app/services/mulenpay_service.py @@ -0,0 +1,126 @@ +import hashlib +import logging +from typing import Optional, Dict, Any + +import aiohttp + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class MulenPayService: + """Интеграция с Mulen Pay API.""" + + def __init__(self) -> None: + self.api_key = settings.MULENPAY_API_KEY + self.shop_id = settings.MULENPAY_SHOP_ID + self.secret_key = settings.MULENPAY_SECRET_KEY + self.base_url = settings.MULENPAY_BASE_URL.rstrip("/") + + @property + def is_configured(self) -> bool: + return bool( + settings.is_mulenpay_enabled() + and self.api_key + and self.shop_id + and self.secret_key + ) + + async def _request( + self, + method: str, + endpoint: str, + *, + json_data: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + ) -> Optional[Dict[str, Any]]: + if not self.is_configured: + logger.error("MulenPay service is not configured") + return None + + url = f"{self.base_url}{endpoint}" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + try: + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.request( + method, + url, + headers=headers, + json=json_data, + params=params, + ) as response: + data = await response.json(content_type=None) + + if response.status >= 400: + logger.error( + "MulenPay API error %s %s: %s", response.status, endpoint, data + ) + return None + + return data + except aiohttp.ClientError as error: + logger.error("MulenPay API request error: %s", error) + return None + except Exception as error: # pragma: no cover - safety + logger.error("Unexpected MulenPay error: %s", error, exc_info=True) + return None + + @staticmethod + def _format_amount(amount_kopeks: int) -> str: + return f"{amount_kopeks / 100:.2f}" + + def _build_signature(self, currency: str, amount_str: str) -> str: + raw_string = f"{currency}{amount_str}{self.shop_id}{self.secret_key}".encode() + return hashlib.sha1(raw_string).hexdigest() + + async def create_payment( + self, + *, + amount_kopeks: int, + description: str, + uuid: str, + items: list, + language: str = "ru", + subscribe: Optional[str] = None, + hold_time: Optional[int] = None, + website_url: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + if not self.is_configured: + logger.error("MulenPay service is not configured") + return None + + amount_str = self._format_amount(amount_kopeks) + currency = "rub" + payload = { + "currency": currency, + "amount": amount_str, + "uuid": uuid, + "shopId": self.shop_id, + "description": description, + "items": items, + "language": language, + "sign": self._build_signature(currency, amount_str), + } + + if subscribe: + payload["subscribe"] = subscribe + if hold_time is not None: + payload["holdTime"] = hold_time + if website_url: + payload["website_url"] = website_url + + response = await self._request("POST", "/v2/payments", json_data=payload) + if not response or not response.get("success"): + logger.error("Failed to create MulenPay payment: %s", response) + return None + + return response + + async def get_payment(self, payment_id: int) -> Optional[Dict[str, Any]]: + return await self._request("GET", f"/v2/payments/{payment_id}") diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 0855be9e..a08992a8 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -1,6 +1,8 @@ import logging import hashlib import hmac +import uuid +from decimal import Decimal, InvalidOperation from typing import Optional, Dict, Any from datetime import datetime from aiogram import Bot @@ -26,6 +28,15 @@ from app.services.subscription_checkout_service import ( has_subscription_checkout_draft, should_offer_checkout_resume, ) +from app.services.mulenpay_service import MulenPayService +from app.database.crud.mulenpay import ( + create_mulenpay_payment, + get_mulenpay_payment_by_local_id, + get_mulenpay_payment_by_uuid, + get_mulenpay_payment_by_mulen_id, + update_mulenpay_payment_status, + link_mulenpay_payment_to_transaction, +) logger = logging.getLogger(__name__) @@ -37,6 +48,7 @@ class PaymentService: self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None self.stars_service = TelegramStarsService(bot) if bot else None self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None + self.mulenpay_service = MulenPayService() if settings.is_mulenpay_enabled() else None async def build_topup_success_keyboard(self, user) -> InlineKeyboardMarkup: texts = get_texts(user.language if user else "ru") @@ -633,7 +645,7 @@ class PaymentService: description: str = "Пополнение баланса", payload: Optional[str] = None ) -> Optional[Dict[str, Any]]: - + if not self.cryptobot_service: logger.error("CryptoBot сервис не инициализирован") return None @@ -686,7 +698,345 @@ class PaymentService: except Exception as e: logger.error(f"Ошибка создания CryptoBot платежа: {e}") return None - + + async def create_mulenpay_payment( + self, + db: AsyncSession, + user_id: int, + amount_kopeks: int, + description: str, + language: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + + if not self.mulenpay_service: + logger.error("MulenPay сервис не инициализирован") + return None + + try: + payment_uuid = f"mulen_{user_id}_{uuid.uuid4().hex}" + amount_rubles = amount_kopeks / 100 + + items = [ + { + "description": description[:128], + "quantity": 1, + "price": round(amount_rubles, 2), + "vat_code": settings.MULENPAY_VAT_CODE, + "payment_subject": settings.MULENPAY_PAYMENT_SUBJECT, + "payment_mode": settings.MULENPAY_PAYMENT_MODE, + } + ] + + response = await self.mulenpay_service.create_payment( + amount_kopeks=amount_kopeks, + description=description, + uuid=payment_uuid, + items=items, + language=language or settings.MULENPAY_LANGUAGE, + website_url=settings.WEBHOOK_URL, + ) + + if not response: + logger.error("Ошибка создания MulenPay платежа") + return None + + mulen_payment_id = response.get("id") + payment_url = response.get("paymentUrl") + + metadata = { + "user_id": user_id, + "amount_kopeks": amount_kopeks, + "description": description, + } + + local_payment = await create_mulenpay_payment( + db=db, + user_id=user_id, + amount_kopeks=amount_kopeks, + uuid=payment_uuid, + description=description, + payment_url=payment_url, + mulen_payment_id=mulen_payment_id, + currency="RUB", + status="created", + metadata=metadata, + ) + + logger.info( + "Создан MulenPay платеж %s на %s₽ для пользователя %s", + mulen_payment_id, + amount_rubles, + user_id, + ) + + return { + "local_payment_id": local_payment.id, + "mulen_payment_id": mulen_payment_id, + "payment_url": payment_url, + "amount_kopeks": amount_kopeks, + "uuid": payment_uuid, + "status": "created", + } + + except Exception as e: + logger.error(f"Ошибка создания MulenPay платежа: {e}") + return None + + async def process_mulenpay_callback(self, db: AsyncSession, callback_data: dict) -> bool: + try: + uuid_value = callback_data.get("uuid") + payment_status = (callback_data.get("payment_status") or "").lower() + mulen_payment_id_raw = callback_data.get("id") + mulen_payment_id_int: Optional[int] = None + if mulen_payment_id_raw is not None: + try: + mulen_payment_id_int = int(mulen_payment_id_raw) + except (TypeError, ValueError): + mulen_payment_id_int = None + amount_value = callback_data.get("amount") + + if not uuid_value and mulen_payment_id_raw is None: + logger.error("MulenPay callback без uuid и id") + return False + + payment = None + if uuid_value: + payment = await get_mulenpay_payment_by_uuid(db, uuid_value) + + if not payment and mulen_payment_id_int is not None: + payment = await get_mulenpay_payment_by_mulen_id(db, mulen_payment_id_int) + + if not payment: + logger.error( + "MulenPay платеж не найден (uuid=%s, id=%s)", + uuid_value, + mulen_payment_id_raw, + ) + return False + + if payment.transaction_id and payment.is_paid: + logger.info("MulenPay платеж %s уже обработан", payment.uuid) + return True + + paid_at = datetime.utcnow() + + if payment_status == "success": + try: + amount_kopeks = int(Decimal(str(amount_value)) * 100) + except (InvalidOperation, TypeError): + amount_kopeks = payment.amount_kopeks + logger.warning( + "Не удалось распарсить сумму MulenPay, используем значение из БД: %s", + amount_value, + ) + + if amount_kopeks != payment.amount_kopeks: + logger.warning( + "Несовпадение суммы MulenPay: callback=%s, ожидаемо=%s", + amount_kopeks, + payment.amount_kopeks, + ) + + transaction = await create_transaction( + db, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + amount_kopeks=payment.amount_kopeks, + description=f"Пополнение через Mulen Pay ({mulen_payment_id_raw})", + payment_method=PaymentMethod.MULENPAY, + external_id=( + str(mulen_payment_id_int) + if mulen_payment_id_int is not None + else payment.uuid + ), + is_completed=True, + ) + + await link_mulenpay_payment_to_transaction( + db, + payment=payment, + transaction_id=transaction.id, + ) + + user = await get_user_by_id(db, payment.user_id) + if not user: + logger.error("Пользователь %s не найден для MulenPay платежа", payment.user_id) + return False + + old_balance = user.balance_kopeks + user.balance_kopeks += payment.amount_kopeks + user.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(user) + + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup(db, user.id, payment.amount_kopeks, self.bot) + except Exception as referral_error: + logger.error( + "Ошибка обработки реферального пополнения MulenPay: %s", + referral_error, + ) + + await update_mulenpay_payment_status( + db, + payment=payment, + status="success", + is_paid=True, + paid_at=paid_at, + callback_payload=callback_data, + mulen_payment_id=mulen_payment_id_int, + ) + + if self.bot: + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(self.bot) + await notification_service.send_balance_topup_notification( + db, + user, + transaction, + old_balance, + ) + except Exception as notify_error: + logger.error( + "Ошибка отправки админ уведомления MulenPay: %s", + notify_error, + ) + + if self.bot: + try: + keyboard = await self.build_topup_success_keyboard(user) + await self.bot.send_message( + user.telegram_id, + ( + "✅ Пополнение успешно!\n\n" + f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n" + "🦊 Способ: Mulen Pay\n" + f"🆔 Транзакция: {transaction.id}\n\n" + "Баланс пополнен автоматически!" + ), + parse_mode="HTML", + reply_markup=keyboard, + ) + except Exception as user_notify_error: + logger.error( + "Ошибка отправки уведомления пользователю MulenPay: %s", + user_notify_error, + ) + + logger.info( + "✅ Обработан MulenPay платеж %s для пользователя %s", + payment.uuid, + payment.user_id, + ) + return True + + if payment_status == "cancel": + await update_mulenpay_payment_status( + db, + payment=payment, + status="canceled", + callback_payload=callback_data, + mulen_payment_id=mulen_payment_id_int, + ) + logger.info("MulenPay платеж %s отменен", payment.uuid) + return True + + await update_mulenpay_payment_status( + db, + payment=payment, + status=payment_status or "unknown", + callback_payload=callback_data, + mulen_payment_id=mulen_payment_id_int, + ) + logger.info( + "Получен MulenPay callback со статусом %s для платежа %s", + payment_status, + payment.uuid, + ) + return True + + except Exception as error: + logger.error(f"Ошибка обработки MulenPay callback: {error}", exc_info=True) + return False + + @staticmethod + def _map_mulenpay_status(status_code: Optional[int]) -> str: + mapping = { + 0: "created", + 1: "processing", + 2: "canceled", + 3: "success", + 4: "error", + 5: "hold", + 6: "hold", + } + return mapping.get(status_code, "unknown") + + async def get_mulenpay_payment_status( + self, + db: AsyncSession, + local_payment_id: int, + ) -> Optional[Dict[str, Any]]: + try: + payment = await get_mulenpay_payment_by_local_id(db, local_payment_id) + if not payment: + return None + + remote_status_code = None + remote_data = None + + if ( + self.mulenpay_service + and payment.mulen_payment_id is not None + ): + response = await self.mulenpay_service.get_payment(payment.mulen_payment_id) + if response and response.get("success"): + remote_data = response.get("payment") + if isinstance(remote_data, dict): + remote_status_code = remote_data.get("status") + mapped_status = self._map_mulenpay_status(remote_status_code) + + if mapped_status == "success" and not payment.is_paid: + await self.process_mulenpay_callback( + db, + { + "uuid": payment.uuid, + "payment_status": "success", + "id": remote_data.get("id"), + "amount": remote_data.get("amount"), + }, + ) + payment = await get_mulenpay_payment_by_local_id( + db, local_payment_id + ) + elif mapped_status and mapped_status != payment.status: + await update_mulenpay_payment_status( + db, + payment=payment, + status=mapped_status, + mulen_payment_id=remote_data.get("id"), + ) + payment = await get_mulenpay_payment_by_local_id( + db, local_payment_id + ) + + return { + "payment": payment, + "status": payment.status, + "is_paid": payment.is_paid, + "remote_status_code": remote_status_code, + "remote_data": remote_data, + } + + except Exception as error: + logger.error(f"Ошибка получения статуса MulenPay: {error}", exc_info=True) + return None + async def process_cryptobot_webhook(self, db: AsyncSession, webhook_data: dict) -> bool: try: from app.database.crud.cryptobot import ( diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py index 4b6ecaa4..0bc6191f 100644 --- a/app/utils/payment_utils.py +++ b/app/utils/payment_utils.py @@ -30,17 +30,26 @@ def get_available_payment_methods() -> List[Dict[str, str]]: if settings.TRIBUTE_ENABLED: methods.append({ "id": "tribute", - "name": "Банковская карта", + "name": "Банковская карта", "icon": "💳", "description": "через Tribute", "callback": "topup_tribute" }) - + + if settings.is_mulenpay_enabled(): + methods.append({ + "id": "mulenpay", + "name": "Банковская карта", + "icon": "💳", + "description": "через Mulen Pay", + "callback": "topup_mulenpay" + }) + if settings.is_cryptobot_enabled(): methods.append({ "id": "cryptobot", "name": "Криптовалюта", - "icon": "🪙", + "icon": "🪙", "description": "через CryptoBot", "callback": "topup_cryptobot" }) @@ -112,6 +121,8 @@ def is_payment_method_available(method_id: str) -> bool: return settings.is_yookassa_enabled() elif method_id == "tribute": return settings.TRIBUTE_ENABLED + elif method_id == "mulenpay": + return settings.is_mulenpay_enabled() elif method_id == "cryptobot": return settings.is_cryptobot_enabled() elif method_id == "support": @@ -127,6 +138,7 @@ def get_payment_method_status() -> Dict[str, bool]: "stars": settings.TELEGRAM_STARS_ENABLED, "yookassa": settings.is_yookassa_enabled(), "tribute": settings.TRIBUTE_ENABLED, + "mulenpay": settings.is_mulenpay_enabled(), "cryptobot": settings.is_cryptobot_enabled(), "support": True } @@ -139,9 +151,11 @@ def get_enabled_payment_methods_count() -> int: if settings.TELEGRAM_STARS_ENABLED: count += 1 if settings.is_yookassa_enabled(): - count += 1 + count += 1 if settings.TRIBUTE_ENABLED: count += 1 + if settings.is_mulenpay_enabled(): + count += 1 if settings.is_cryptobot_enabled(): count += 1 return count \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index e72fd5e4..b3d62a39 100644 --- a/locales/en.json +++ b/locales/en.json @@ -56,6 +56,7 @@ "PAGINATION_PREV": "⬅️", "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable", "PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)", + "PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)", "PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)", "PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)", "PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)", @@ -63,6 +64,10 @@ "PAYMENT_VIA_SUPPORT": "🛠️ Via support", "PAY_NOW_BUTTON": "💳 Pay", "PAY_WITH_COINS_BUTTON": "🪙 Pay", + "MULENPAY_TOPUP_PROMPT": "💳 Mulen Pay payment\n\nEnter an amount between 100 and 100,000 ₽.\nThe payment is processed by the secure Mulen Pay platform.", + "MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.", + "MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay", + "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Mulen Pay payment\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 How to pay:\n1. Press ‘Pay with Mulen Pay’\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", "PENDING_CANCEL_BUTTON": "⌛ Cancel", "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀", "REFERRAL_ANALYTICS_BUTTON": "📊 Analytics", @@ -444,6 +449,8 @@ "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa", "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Bank card", "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", + "PAYMENT_METHOD_MULENPAY_NAME": "💳 Bank card (Mulen Pay)", + "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", diff --git a/locales/ru.json b/locales/ru.json index cb12e4c5..6c84da2c 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -214,6 +214,7 @@ "PAGINATION_PREV": "⬅️", "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны", "PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)", + "PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)", "PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)", "PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)", "PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)", @@ -221,6 +222,10 @@ "PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку", "PAY_NOW_BUTTON": "💳 Оплатить", "PAY_WITH_COINS_BUTTON": "🪙 Оплатить", + "MULENPAY_TOPUP_PROMPT": "💳 Оплата через Mulen Pay\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу Mulen Pay.", + "MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.", + "MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay", + "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Оплата через Mulen Pay\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", "PENDING_CANCEL_BUTTON": "⌛ Отмена", "PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}", "PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}", @@ -444,6 +449,8 @@ "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банковская карта", "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", + "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банковская карта (Mulen Pay)", + "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", diff --git a/main.py b/main.py index fccd15e8..0ba40b99 100644 --- a/main.py +++ b/main.py @@ -112,12 +112,18 @@ async def main(): payment_service = PaymentService(bot) - webhook_needed = settings.TRIBUTE_ENABLED or settings.is_cryptobot_enabled() + webhook_needed = ( + settings.TRIBUTE_ENABLED + or settings.is_cryptobot_enabled() + or settings.is_mulenpay_enabled() + ) if webhook_needed: enabled_services = [] if settings.TRIBUTE_ENABLED: enabled_services.append("Tribute") + if settings.is_mulenpay_enabled(): + enabled_services.append("Mulen Pay") if settings.is_cryptobot_enabled(): enabled_services.append("CryptoBot") @@ -160,6 +166,8 @@ async def main(): if webhook_needed: if settings.TRIBUTE_ENABLED: logger.info(f" Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}") + if settings.is_mulenpay_enabled(): + logger.info(f" Mulen Pay: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}") if settings.is_cryptobot_enabled(): logger.info(f" CryptoBot: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}") if settings.is_yookassa_enabled(): From 588e4acbd38c8c2e54385000ac4f20226666651d Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 01:29:42 +0300 Subject: [PATCH 009/146] Secure Mulen Pay webhook with signature validation --- app/external/webhook_server.py | 40 +++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index 0a2d0003..9ea90945 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -1,3 +1,5 @@ +import hashlib +import hmac import logging import json from typing import Optional @@ -102,7 +104,7 @@ class WebhookServer: headers={ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature', + 'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization', } ) @@ -115,6 +117,9 @@ class WebhookServer: logger.warning("Пустой Mulen Pay webhook") return web.json_response({"status": "error", "reason": "empty_body"}, status=400) + if not self._verify_mulenpay_signature(request, raw_body): + return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401) + try: payload = json.loads(raw_body.decode('utf-8')) except json.JSONDecodeError as error: @@ -139,6 +144,39 @@ class WebhookServer: logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True) return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500) + @staticmethod + def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool: + secret_key = settings.MULENPAY_SECRET_KEY + if not secret_key: + logger.error("Mulen Pay secret key is not configured") + return False + + signature = request.headers.get('X-MulenPay-Signature') + if signature: + expected_signature = hmac.new( + secret_key.encode('utf-8'), + raw_body, + hashlib.sha256, + ).hexdigest() + + if hmac.compare_digest(signature.strip().lower(), expected_signature.lower()): + return True + + logger.error("Неверная подпись Mulen Pay webhook") + return False + + authorization_header = request.headers.get('Authorization') + if authorization_header and authorization_header.startswith('Bearer '): + token = authorization_header.split(' ', 1)[1].strip() + if hmac.compare_digest(token, secret_key): + return True + + logger.error("Неверный Bearer токен Mulen Pay webhook") + return False + + logger.error("Отсутствует подпись Mulen Pay webhook") + return False + async def _tribute_webhook_handler(self, request: web.Request) -> web.Response: try: From 2cc18422fd3f8b0173af74fa2ddf3ee3f7f04c9b Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 01:35:56 +0300 Subject: [PATCH 010/146] Update .env.example --- .env.example | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index a0ef3cc5..6fadd322 100644 --- a/.env.example +++ b/.env.example @@ -221,7 +221,7 @@ PAYMENT_BALANCE_TEMPLATE={service_name} - {description} PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description} # CRYPTOBOT -CRYPTOBOT_ENABLED=true +CRYPTOBOT_ENABLED=false CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here CRYPTOBOT_BASE_URL=https://pay.crypt.bot @@ -232,6 +232,20 @@ CRYPTOBOT_DEFAULT_ASSET=USDT CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC CRYPTOBOT_INVOICE_EXPIRES_HOURS=24 +# MULENPAY +MULENPAY_ENABLED=false +MULENPAY_API_KEY= +MULENPAY_SECRET_KEY= +MULENPAY_SHOP_ID= +# необязательно, есть дефолтные значения +MULENPAY_BASE_URL=https://mulenpay.ru/api +MULENPAY_WEBHOOK_PATH=/mulenpay-webhook +MULENPAY_DESCRIPTION="Пополнение баланса" +MULENPAY_LANGUAGE=ru +MULENPAY_VAT_CODE=0 +MULENPAY_PAYMENT_SUBJECT=4 +MULENPAY_PAYMENT_MODE=4 + # ===== ИНТЕРФЕЙС И UX ===== # Включить логотип для всех сообщений (true - с изображением, false - только текст) @@ -330,4 +344,4 @@ LOG_FILE=logs/bot.log # ===== РАЗРАБОТКА ===== DEBUG=false WEBHOOK_URL= -WEBHOOK_PATH=/webhook \ No newline at end of file +WEBHOOK_PATH=/webhook From d471a841255e224e1686bdde41854cd3387757dc Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 01:40:21 +0300 Subject: [PATCH 011/146] Update README.md --- README.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 3c6f7796..1f15091c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ ### ⚡ **Полная автоматизация VPN бизнеса** - 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня -- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + P2P +- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + MulenPay + P2P - 🔄 **Автоматизация 99%** - от регистрации до продления подписок - 📊 **Детальная аналитика** - полная картина вашего бизнеса - 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота @@ -248,11 +248,12 @@ ADMIN_IDS= # Ссылка на поддержку: Telegram username (например, @support) или полный URL SUPPORT_USERNAME=@support + # Уведомления администраторов ADMIN_NOTIFICATIONS_ENABLED=true ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ! ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика - +ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов # Обязательная подписка на канал CHANNEL_SUB_ID= # Опционально ID твоего канала (-100) CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал @@ -274,6 +275,7 @@ POSTGRES_PASSWORD=secure_password_123 # SQLite настройки (для локального запуска) SQLITE_PATH=./data/bot.db +LOCALES_PATH=./locales # Redis REDIS_URL=redis://redis:6379/0 @@ -300,6 +302,11 @@ REMNAWAVE_SECRET_KEY= # {telegram_id} — ID Telegram REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}" +# Режим удаления пользователей из панели RemnaWave +# delete - полностью удалить пользователя из панели +# disable - только деактивировать пользователя +REMNAWAVE_USER_DELETE_MODE=delete + # ========= ПОДПИСКИ ========= # ===== ТРИАЛ ПОДПИСКА ===== TRIAL_DURATION_DAYS=3 @@ -437,6 +444,13 @@ YOOKASSA_WEBHOOK_PATH=/yookassa-webhook YOOKASSA_WEBHOOK_PORT=8082 YOOKASSA_WEBHOOK_SECRET=your_webhook_secret +# Лимиты сумм пополнения через YooKassa (в копейках) +YOOKASSA_MIN_AMOUNT_KOPEKS=5000 +YOOKASSA_MAX_AMOUNT_KOPEKS=1000000 + +# Быстрый выбор суммы пополнения через YooKassa +YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true + # ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ ===== # Эти настройки позволяют изменить описания платежей, # чтобы избежать блокировок платежных систем @@ -447,7 +461,7 @@ PAYMENT_BALANCE_TEMPLATE={service_name} - {description} PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description} # CRYPTOBOT -CRYPTOBOT_ENABLED=true +CRYPTOBOT_ENABLED=false CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here CRYPTOBOT_BASE_URL=https://pay.crypt.bot @@ -458,6 +472,20 @@ CRYPTOBOT_DEFAULT_ASSET=USDT CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC CRYPTOBOT_INVOICE_EXPIRES_HOURS=24 +# MULENPAY +MULENPAY_ENABLED=false +MULENPAY_API_KEY= +MULENPAY_SECRET_KEY= +MULENPAY_SHOP_ID= +# необязательно, есть дефолтные значения +MULENPAY_BASE_URL=https://mulenpay.ru/api +MULENPAY_WEBHOOK_PATH=/mulenpay-webhook +MULENPAY_DESCRIPTION="Пополнение баланса" +MULENPAY_LANGUAGE=ru +MULENPAY_VAT_CODE=0 +MULENPAY_PAYMENT_SUBJECT=4 +MULENPAY_PAYMENT_MODE=4 + # ===== ИНТЕРФЕЙС И UX ===== # Включить логотип для всех сообщений (true - с изображением, false - только текст) @@ -493,6 +521,23 @@ NOTIFICATION_RETRY_ATTEMPTS=3 MONITORING_LOGS_RETENTION_DAYS=30 NOTIFICATION_CACHE_HOURS=24 +# ===== СТАТУС СЕРВЕРОВ ===== +# Режимы: disabled, external_link, xray +SERVER_STATUS_MODE=disabled +# Ссылка на внешний мониторинг (для режима external_link) +SERVER_STATUS_EXTERNAL_URL= +# URL метрик XrayChecker (для режима xray) +SERVER_STATUS_METRICS_URL= +# Данные Basic Auth (опционально) +SERVER_STATUS_METRICS_USERNAME= +SERVER_STATUS_METRICS_PASSWORD= +# Проверять SSL сертификат при запросе метрик +SERVER_STATUS_METRICS_VERIFY_SSL=true +# Таймаут запроса к метрикам (в секундах) +SERVER_STATUS_REQUEST_TIMEOUT=10 +# Количество серверов на странице в режиме интеграции +SERVER_STATUS_ITEMS_PER_PAGE=10 + # ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ ===== MAINTENANCE_MODE=false MAINTENANCE_CHECK_INTERVAL=30 @@ -540,8 +585,6 @@ LOG_FILE=logs/bot.log DEBUG=false WEBHOOK_URL= WEBHOOK_PATH=/webhook - - ``` @@ -573,6 +616,7 @@ WEBHOOK_PATH=/webhook - ⭐ Telegram Stars - 💳 Tribute - 💳 YooKassa (включая СБП и онлайн-чек) +- 💳 MulenPay - 💰 CryptoBot (мультивалюта и срок жизни инвойсов) - 🎁 Реферальные и промо-бонусы - Детальная история транзакций и чеков @@ -1124,13 +1168,23 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - + # CryptoBot webhook endpoint - handle /cryptobot-webhook* { - reverse_proxy localhost:8081 { - header_up Host {host} - header_up X-Real-IP {remote_host} - } + location /cryptobot-webhook { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # MulenPay webhook endpoint + location /mulenpay-webhook { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } # Для YooKassa @@ -1138,6 +1192,8 @@ server { proxy_pass http://127.0.0.1:8082; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } # Health check @@ -1153,11 +1209,15 @@ your-domain.com { handle /tribute-webhook* { reverse_proxy localhost:8081 } - + handle /cryptobot-webhook* { reverse_proxy localhost:8081 } + handle /mulenpay-webhook* { + reverse_proxy localhost:8081 + } + handle /yookassa-webhook* { reverse_proxy localhost:8082 } From 4225404673de8db4ea307ed894b4e4735d54e619 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 02:56:51 +0300 Subject: [PATCH 012/146] Add PayPalych payment integration --- README.md | 65 ++++- app/config.py | 19 ++ app/database/crud/pal24.py | 160 +++++++++++ app/database/models.py | 63 +++++ app/database/universal_migration.py | 151 ++++++++++ app/external/pal24_client.py | 216 +++++++++++++++ app/external/pal24_webhook.py | 150 ++++++++++ app/handlers/balance.py | 219 +++++++++++++++ app/keyboards/inline.py | 8 + app/services/admin_notification_service.py | 1 + app/services/pal24_service.py | 116 ++++++++ app/services/payment_service.py | 306 +++++++++++++++++++++ app/utils/payment_utils.py | 14 + locales/en.json | 7 + locales/ru.json | 7 + main.py | 18 +- requirements.txt | 3 + 17 files changed, 1516 insertions(+), 7 deletions(-) create mode 100644 app/database/crud/pal24.py create mode 100644 app/external/pal24_client.py create mode 100644 app/external/pal24_webhook.py create mode 100644 app/services/pal24_service.py diff --git a/README.md b/README.md index 1f15091c..b73295b6 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ ### ⚡ **Полная автоматизация VPN бизнеса** - 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня -- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + MulenPay + P2P +- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + MulenPay + PayPalych + P2P - 🔄 **Автоматизация 99%** - от регистрации до продления подписок - 📊 **Детальная аналитика** - полная картина вашего бизнеса - 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота @@ -486,6 +486,25 @@ MULENPAY_VAT_CODE=0 MULENPAY_PAYMENT_SUBJECT=4 MULENPAY_PAYMENT_MODE=4 +# PAYPALYCH / PAL24 +PAL24_ENABLED=false +PAL24_API_TOKEN= +PAL24_SHOP_ID= +PAL24_SIGNATURE_TOKEN= +PAL24_BASE_URL=https://pal24.pro/api/v1/ +PAL24_WEBHOOK_PATH=/pal24-webhook +PAL24_WEBHOOK_PORT=8084 +PAL24_PAYMENT_DESCRIPTION="Пополнение баланса" +PAL24_MIN_AMOUNT_KOPEKS=10000 +PAL24_MAX_AMOUNT_KOPEKS=100000000 +PAL24_REQUEST_TIMEOUT=30 + +# Настройки PayPalych +1. Включите интеграцию (`PAL24_ENABLED=true`) и укажите `PAL24_API_TOKEN`, `PAL24_SHOP_ID`, а также `PAL24_SIGNATURE_TOKEN` для проверки подписи уведомлений. +2. Настройте в кабинете PayPalych **Result URL** и success/fail redirect на `https://<ваш-домен>/pal24-webhook`. +3. Убедитесь, что порт `PAL24_WEBHOOK_PORT` (по умолчанию `8084`) проброшен через прокси/фаервол. +4. Для теста можно отправить postback вручную (пример команды см. ниже в разделе «Проверка PayPalych postback»). + # ===== ИНТЕРФЕЙС И UX ===== # Включить логотип для всех сообщений (true - с изображением, false - только текст) @@ -617,6 +636,7 @@ WEBHOOK_PATH=/webhook - 💳 Tribute - 💳 YooKassa (включая СБП и онлайн-чек) - 💳 MulenPay +- 💳 PayPalych (Pal24) - 💰 CryptoBot (мультивалюта и срок жизни инвойсов) - 🎁 Реферальные и промо-бонусы - Детальная история транзакций и чеков @@ -642,7 +662,7 @@ WEBHOOK_PATH=/webhook 📊 **Мощная аналитика** - 👥 Детальная статистика пользователей и подписок -- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, CryptoBot) +- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, MulenPay, PayPalych, CryptoBot) - 🖥️ Мониторинг серверов Remnawave и статуса сквадов - 📈 Финансовые отчеты, конверсии и эффективность рекламных кампаний @@ -935,6 +955,7 @@ docker compose down -v --remove-orphans - **Telegram Stars**: Работает автоматически - **Tribute**: Настрой webhook на `https://your-domain.com/tribute-webhook` - **YooKassa**: Настрой webhook на `https://your-domain.com/yookassa-webhook` + - **PayPalych**: Укажи Result URL `https://your-domain.com/pal24-webhook` в кабинете Pal24 ### 🛠️ Настройка Уведомлений в топик группы @@ -1147,7 +1168,7 @@ docker system prune |----------|-------------|---------| | **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет | | **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL | -| **Webhook не работает** | Проверь порты 8081/8082 | Настрой прокси-сервер правильно | +| **Webhook не работает** | Проверь порты 8081/8082/8084 | Настрой прокси-сервер правильно | | **API недоступен** | Проверь логи бота | Проверь `REMNAWAVE_API_URL` и ключ | | **Мониторинг не работает** | Админ панель → Мониторинг | Проверь `MAINTENANCE_AUTO_ENABLE` | | **Платежи не проходят** | Проверь webhook'и | Настрой URL в платежных системах | @@ -1186,7 +1207,16 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - + + # PayPalych webhook endpoint + location /pal24-webhook { + proxy_pass http://127.0.0.1:8084; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Для YooKassa location /yookassa-webhook { proxy_pass http://127.0.0.1:8082; @@ -1217,17 +1247,40 @@ your-domain.com { handle /mulenpay-webhook* { reverse_proxy localhost:8081 } - + + handle /pal24-webhook* { + reverse_proxy localhost:8084 + } + handle /yookassa-webhook* { reverse_proxy localhost:8082 } - + handle /health { reverse_proxy localhost:8081/health } } ``` +#### 🧪 Проверка PayPalych postback + +```bash +# Генерируем подпись: md5("100.00:test-order-1:${PAL24_SIGNATURE_TOKEN}") +SIGNATURE=$(python - <<'PY' +import hashlib, os +token = os.environ.get('PAL24_SIGNATURE_TOKEN', 'test_token') +payload = f"100.00:test-order-1:{token}".encode() +print(hashlib.md5(payload).hexdigest().upper()) +PY +) + +curl -X POST https://your-domain.com/pal24-webhook \ + -H "Content-Type: application/json" \ + -d '{"InvId": "test-order-1", "OutSum": "100.00", "Status": "SUCCESS", "SignatureValue": "'$SIGNATURE'"}' +``` + +Ответ `{"status": "ok"}` подтверждает корректную обработку вебхука. + --- ## 💡 Использование diff --git a/app/config.py b/app/config.py index 1824490e..6c038b5a 100644 --- a/app/config.py +++ b/app/config.py @@ -186,6 +186,18 @@ class Settings(BaseSettings): MULENPAY_PAYMENT_SUBJECT: int = 4 MULENPAY_PAYMENT_MODE: int = 4 + PAL24_ENABLED: bool = False + PAL24_API_TOKEN: Optional[str] = None + PAL24_SHOP_ID: Optional[str] = None + PAL24_SIGNATURE_TOKEN: Optional[str] = None + PAL24_BASE_URL: str = "https://pal24.pro/api/v1/" + PAL24_WEBHOOK_PATH: str = "/pal24-webhook" + PAL24_WEBHOOK_PORT: int = 8084 + PAL24_PAYMENT_DESCRIPTION: str = "Пополнение баланса" + PAL24_MIN_AMOUNT_KOPEKS: int = 10000 + PAL24_MAX_AMOUNT_KOPEKS: int = 100000000 + PAL24_REQUEST_TIMEOUT: int = 30 + CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" HIDE_SUBSCRIPTION_LINK: bool = False @@ -463,6 +475,13 @@ class Settings(BaseSettings): and self.MULENPAY_SHOP_ID is not None ) + def is_pal24_enabled(self) -> bool: + return ( + self.PAL24_ENABLED + and self.PAL24_API_TOKEN is not None + and self.PAL24_SHOP_ID is not None + ) + def get_cryptobot_base_url(self) -> str: if self.CRYPTOBOT_TESTNET: return "https://testnet-pay.crypt.bot" diff --git a/app/database/crud/pal24.py b/app/database/crud/pal24.py new file mode 100644 index 00000000..42a03015 --- /dev/null +++ b/app/database/crud/pal24.py @@ -0,0 +1,160 @@ +"""CRUD helpers for PayPalych (Pal24) payments.""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import Pal24Payment + +logger = logging.getLogger(__name__) + + +async def create_pal24_payment( + db: AsyncSession, + *, + user_id: int, + bill_id: str, + amount_kopeks: int, + description: Optional[str], + status: str, + type_: str, + currency: str, + link_url: Optional[str], + link_page_url: Optional[str], + order_id: Optional[str] = None, + ttl: Optional[int] = None, + metadata: Optional[Dict[str, Any]] = None, +) -> Pal24Payment: + payment = Pal24Payment( + user_id=user_id, + bill_id=bill_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + status=status, + type=type_, + link_url=link_url, + link_page_url=link_page_url, + metadata_json=metadata or {}, + ttl=ttl, + ) + + db.add(payment) + await db.commit() + await db.refresh(payment) + + logger.info( + "Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)", + payment.id, + user_id, + amount_kopeks, + status, + ) + + return payment + + +async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]: + result = await db.execute( + select(Pal24Payment).where(Pal24Payment.id == payment_id) + ) + return result.scalar_one_or_none() + + +async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]: + result = await db.execute( + select(Pal24Payment).where(Pal24Payment.bill_id == bill_id) + ) + return result.scalar_one_or_none() + + +async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]: + result = await db.execute( + select(Pal24Payment).where(Pal24Payment.order_id == order_id) + ) + return result.scalar_one_or_none() + + +async def update_pal24_payment_status( + db: AsyncSession, + payment: Pal24Payment, + *, + status: str, + is_active: Optional[bool] = None, + is_paid: Optional[bool] = None, + payment_id: Optional[str] = None, + payment_status: Optional[str] = None, + payment_method: Optional[str] = None, + balance_amount: Optional[str] = None, + balance_currency: Optional[str] = None, + payer_account: Optional[str] = None, + callback_payload: Optional[Dict[str, Any]] = None, +) -> Pal24Payment: + update_values: Dict[str, Any] = { + "status": status, + } + + if is_active is not None: + update_values["is_active"] = is_active + if is_paid is not None: + update_values["is_paid"] = is_paid + if payment_id is not None: + update_values["payment_id"] = payment_id + if payment_status is not None: + update_values["payment_status"] = payment_status + if payment_method is not None: + update_values["payment_method"] = payment_method + if balance_amount is not None: + update_values["balance_amount"] = balance_amount + if balance_currency is not None: + update_values["balance_currency"] = balance_currency + if payer_account is not None: + update_values["payer_account"] = payer_account + if callback_payload is not None: + update_values["callback_payload"] = callback_payload + + update_values["last_status"] = status + + await db.execute( + update(Pal24Payment) + .where(Pal24Payment.id == payment.id) + .values(**update_values) + ) + + await db.commit() + await db.refresh(payment) + + logger.info( + "Обновлен Pal24 платеж %s: статус=%s, is_paid=%s", + payment.bill_id, + payment.status, + payment.is_paid, + ) + + return payment + + +async def link_pal24_payment_to_transaction( + db: AsyncSession, + payment: Pal24Payment, + transaction_id: int, +) -> Pal24Payment: + await db.execute( + update(Pal24Payment) + .where(Pal24Payment.id == payment.id) + .values(transaction_id=transaction_id) + ) + await db.commit() + await db.refresh(payment) + logger.info( + "Pal24 платеж %s привязан к транзакции %s", + payment.bill_id, + transaction_id, + ) + return payment + diff --git a/app/database/models.py b/app/database/models.py index 3db2df8a..1c67713f 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -56,6 +56,7 @@ class PaymentMethod(Enum): YOOKASSA = "yookassa" CRYPTOBOT = "cryptobot" MULENPAY = "mulenpay" + PAL24 = "pal24" MANUAL = "manual" class YooKassaPayment(Base): @@ -199,6 +200,68 @@ class MulenPayPayment(Base): ) +class Pal24Payment(Base): + __tablename__ = "pal24_payments" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + bill_id = Column(String(255), unique=True, nullable=False, index=True) + order_id = Column(String(255), nullable=True, index=True) + amount_kopeks = Column(Integer, nullable=False) + currency = Column(String(10), nullable=False, default="RUB") + description = Column(Text, nullable=True) + type = Column(String(20), nullable=False, default="normal") + + status = Column(String(50), nullable=False, default="NEW") + is_active = Column(Boolean, default=True) + is_paid = Column(Boolean, default=False) + paid_at = Column(DateTime, nullable=True) + last_status = Column(String(50), nullable=True) + last_status_checked_at = Column(DateTime, nullable=True) + + link_url = Column(Text, nullable=True) + link_page_url = Column(Text, nullable=True) + metadata_json = Column(JSON, nullable=True) + callback_payload = Column(JSON, nullable=True) + + payment_id = Column(String(255), nullable=True, index=True) + payment_status = Column(String(50), nullable=True) + payment_method = Column(String(50), nullable=True) + balance_amount = Column(String(50), nullable=True) + balance_currency = Column(String(10), nullable=True) + payer_account = Column(String(255), nullable=True) + + ttl = Column(Integer, nullable=True) + expires_at = Column(DateTime, nullable=True) + + transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + user = relationship("User", backref="pal24_payments") + transaction = relationship("Transaction", backref="pal24_payment") + + @property + def amount_rubles(self) -> float: + return self.amount_kopeks / 100 + + @property + def is_pending(self) -> bool: + return self.status in {"NEW", "PROCESS"} + + def __repr__(self) -> str: # pragma: no cover - debug helper + return ( + "".format( + self.id, + self.bill_id, + self.amount_rubles, + self.status, + ) + ) + + class PromoGroup(Base): __tablename__ = "promo_groups" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index c122b86c..6c3c7853 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -376,6 +376,150 @@ async def create_mulenpay_payments_table(): logger.error(f"Ошибка создания таблицы mulenpay_payments: {e}") return False + +async def create_pal24_payments_table(): + table_exists = await check_table_exists('pal24_payments') + if table_exists: + logger.info("Таблица pal24_payments уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + create_sql = """ + CREATE TABLE pal24_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + bill_id VARCHAR(255) NOT NULL UNIQUE, + order_id VARCHAR(255) NULL, + amount_kopeks INTEGER NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'RUB', + description TEXT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'normal', + status VARCHAR(50) NOT NULL DEFAULT 'NEW', + is_active BOOLEAN NOT NULL DEFAULT 1, + is_paid BOOLEAN NOT NULL DEFAULT 0, + paid_at DATETIME NULL, + last_status VARCHAR(50) NULL, + last_status_checked_at DATETIME NULL, + link_url TEXT NULL, + link_page_url TEXT NULL, + metadata_json JSON NULL, + callback_payload JSON NULL, + payment_id VARCHAR(255) NULL, + payment_status VARCHAR(50) NULL, + payment_method VARCHAR(50) NULL, + balance_amount VARCHAR(50) NULL, + balance_currency VARCHAR(10) NULL, + payer_account VARCHAR(255) NULL, + ttl INTEGER NULL, + expires_at DATETIME NULL, + transaction_id INTEGER NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (transaction_id) REFERENCES transactions(id) + ); + + CREATE INDEX idx_pal24_bill_id ON pal24_payments(bill_id); + CREATE INDEX idx_pal24_order_id ON pal24_payments(order_id); + CREATE INDEX idx_pal24_payment_id ON pal24_payments(payment_id); + """ + + elif db_type == 'postgresql': + create_sql = """ + CREATE TABLE pal24_payments ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + bill_id VARCHAR(255) NOT NULL UNIQUE, + order_id VARCHAR(255) NULL, + amount_kopeks INTEGER NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'RUB', + description TEXT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'normal', + status VARCHAR(50) NOT NULL DEFAULT 'NEW', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_paid BOOLEAN NOT NULL DEFAULT FALSE, + paid_at TIMESTAMP NULL, + last_status VARCHAR(50) NULL, + last_status_checked_at TIMESTAMP NULL, + link_url TEXT NULL, + link_page_url TEXT NULL, + metadata_json JSON NULL, + callback_payload JSON NULL, + payment_id VARCHAR(255) NULL, + payment_status VARCHAR(50) NULL, + payment_method VARCHAR(50) NULL, + balance_amount VARCHAR(50) NULL, + balance_currency VARCHAR(10) NULL, + payer_account VARCHAR(255) NULL, + ttl INTEGER NULL, + expires_at TIMESTAMP NULL, + transaction_id INTEGER NULL REFERENCES transactions(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX idx_pal24_bill_id ON pal24_payments(bill_id); + CREATE INDEX idx_pal24_order_id ON pal24_payments(order_id); + CREATE INDEX idx_pal24_payment_id ON pal24_payments(payment_id); + """ + + elif db_type == 'mysql': + create_sql = """ + CREATE TABLE pal24_payments ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + bill_id VARCHAR(255) NOT NULL UNIQUE, + order_id VARCHAR(255) NULL, + amount_kopeks INT NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'RUB', + description TEXT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'normal', + status VARCHAR(50) NOT NULL DEFAULT 'NEW', + is_active BOOLEAN NOT NULL DEFAULT 1, + is_paid BOOLEAN NOT NULL DEFAULT 0, + paid_at DATETIME NULL, + last_status VARCHAR(50) NULL, + last_status_checked_at DATETIME NULL, + link_url TEXT NULL, + link_page_url TEXT NULL, + metadata_json JSON NULL, + callback_payload JSON NULL, + payment_id VARCHAR(255) NULL, + payment_status VARCHAR(50) NULL, + payment_method VARCHAR(50) NULL, + balance_amount VARCHAR(50) NULL, + balance_currency VARCHAR(10) NULL, + payer_account VARCHAR(255) NULL, + ttl INT NULL, + expires_at DATETIME NULL, + transaction_id INT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (transaction_id) REFERENCES transactions(id) + ); + + CREATE INDEX idx_pal24_bill_id ON pal24_payments(bill_id); + CREATE INDEX idx_pal24_order_id ON pal24_payments(order_id); + CREATE INDEX idx_pal24_payment_id ON pal24_payments(payment_id); + """ + + else: + logger.error(f"Неподдерживаемый тип БД для таблицы pal24_payments: {db_type}") + return False + + await conn.execute(text(create_sql)) + logger.info("Таблица pal24_payments успешно создана") + return True + + except Exception as e: + logger.error(f"Ошибка создания таблицы pal24_payments: {e}") + return False + async def create_user_messages_table(): table_exists = await check_table_exists('user_messages') if table_exists: @@ -1212,6 +1356,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей Mulen Pay payments") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PAL24 ===") + pal24_created = await create_pal24_payments_table() + if pal24_created: + logger.info("✅ Таблица Pal24 payments готова") + else: + logger.warning("⚠️ Проблемы с таблицей Pal24 payments") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===") user_messages_created = await create_user_messages_table() if user_messages_created: diff --git a/app/external/pal24_client.py b/app/external/pal24_client.py new file mode 100644 index 00000000..4c77a250 --- /dev/null +++ b/app/external/pal24_client.py @@ -0,0 +1,216 @@ +"""Async client for PayPalych (Pal24) API.""" + +from __future__ import annotations + +import asyncio +import hashlib +import logging +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, Optional + +import aiohttp + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class Pal24APIError(Exception): + """Base error for Pal24 API operations.""" + + +@dataclass(slots=True) +class Pal24Response: + """Wrapper for Pal24 API responses.""" + + success: bool + data: Dict[str, Any] + status: int + + @classmethod + def from_payload(cls, payload: Dict[str, Any], status: int) -> "Pal24Response": + success = bool(payload.get("success", status < 400)) + return cls(success=success, data=payload, status=status) + + def raise_for_status(self, endpoint: str) -> None: + if not self.success: + detail = self.data.get("message") or self.data.get("error") + raise Pal24APIError( + f"Pal24 API error at {endpoint}: status={self.status}, detail={detail or self.data}" + ) + + +class Pal24Client: + """Async client implementing PayPalych API methods.""" + + def __init__( + self, + *, + api_token: Optional[str] = None, + base_url: Optional[str] = None, + timeout: Optional[int] = None, + ) -> None: + self.api_token = api_token or settings.PAL24_API_TOKEN + self.base_url = (base_url or settings.PAL24_BASE_URL or "").rstrip("/") + "/" + self.timeout = timeout or settings.PAL24_REQUEST_TIMEOUT + + if not self.api_token: + logger.warning("Pal24Client initialized without API token") + + @property + def is_configured(self) -> bool: + return bool(self.api_token and self.base_url) + + async def _request( + self, + method: str, + endpoint: str, + *, + json_payload: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + ) -> Pal24Response: + if not self.is_configured: + raise Pal24APIError("Pal24 client is not configured") + + url = f"{self.base_url}{endpoint.lstrip('/')}" + headers = { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + timeout = aiohttp.ClientTimeout(total=self.timeout) + + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.request( + method, + url, + headers=headers, + json=json_payload, + params=params, + ) as response: + status = response.status + try: + payload = await response.json(content_type=None) + except aiohttp.ContentTypeError: + text_body = await response.text() + logger.error( + "Pal24 API returned non-JSON response for %s: %s", + endpoint, + text_body, + ) + raise Pal24APIError( + f"Pal24 API returned non-JSON response: {text_body}" + ) from None + + result = Pal24Response.from_payload(payload, status) + if status >= 400 or not result.success: + logger.error( + "Pal24 API error %s %s: %s", + status, + endpoint, + payload, + ) + result.raise_for_status(endpoint) + + return result + + except asyncio.TimeoutError as error: + logger.error("Pal24 API request timeout for %s: %s", endpoint, error) + raise Pal24APIError(f"Pal24 API request timeout for {endpoint}") from error + except aiohttp.ClientError as error: + logger.error("Pal24 API client error for %s: %s", endpoint, error) + raise Pal24APIError(str(error)) from error + + # API methods ----------------------------------------------------------------- + + async def create_bill( + self, + *, + amount: Decimal, + shop_id: str, + order_id: Optional[str] = None, + description: Optional[str] = None, + currency_in: str = "RUB", + type_: str = "normal", + **kwargs: Any, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "amount": str(amount), + "shop_id": shop_id, + "currency_in": currency_in, + "type": type_, + } + + if order_id: + payload["order_id"] = order_id + if description: + payload["description"] = description + + payload.update({k: v for k, v in kwargs.items() if v is not None}) + + response = await self._request("POST", "bill/create", json_payload=payload) + return response.data + + async def get_bill_status(self, bill_id: str) -> Dict[str, Any]: + response = await self._request("GET", "bill/status", params={"id": bill_id}) + return response.data + + async def toggle_bill_activity(self, bill_id: str, active: bool) -> Dict[str, Any]: + payload = {"id": bill_id, "active": 1 if active else 0} + response = await self._request("POST", "bill/toggle_activity", json_payload=payload) + return response.data + + async def search_payments(self, **params: Any) -> Dict[str, Any]: + response = await self._request("GET", "payment/search", params=params) + return response.data + + async def get_payment_status(self, payment_id: str) -> Dict[str, Any]: + response = await self._request("GET", "payment/status", params={"id": payment_id}) + return response.data + + async def get_balance(self) -> Dict[str, Any]: + response = await self._request("GET", "merchant/balance") + return response.data + + async def search_bills(self, **params: Any) -> Dict[str, Any]: + response = await self._request("GET", "bill/search", params=params) + return response.data + + async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]: + response = await self._request("GET", "bill/payments", params={"id": bill_id}) + return response.data + + # Helpers --------------------------------------------------------------------- + + @staticmethod + def calculate_signature(out_sum: str, inv_id: str, api_token: Optional[str] = None) -> str: + token = api_token or settings.PAL24_SIGNATURE_TOKEN or settings.PAL24_API_TOKEN + if not token: + raise Pal24APIError("Pal24 signature token is not configured") + raw = f"{out_sum}:{inv_id}:{token}".encode("utf-8") + return hashlib.md5(raw).hexdigest().upper() + + @staticmethod + def verify_signature( + out_sum: str, + inv_id: str, + signature: str, + api_token: Optional[str] = None, + ) -> bool: + try: + expected = Pal24Client.calculate_signature(out_sum, inv_id, api_token) + except Pal24APIError: + logger.error("Pal24 signature verification failed: missing token") + return False + return expected == signature.upper() + + @staticmethod + def normalize_amount(amount_kopeks: int) -> Decimal: + try: + return (Decimal(amount_kopeks) / Decimal("100")).quantize(Decimal("0.01")) + except (InvalidOperation, TypeError) as error: + raise Pal24APIError(f"Invalid amount: {amount_kopeks}") from error + diff --git a/app/external/pal24_webhook.py b/app/external/pal24_webhook.py new file mode 100644 index 00000000..6f815bd6 --- /dev/null +++ b/app/external/pal24_webhook.py @@ -0,0 +1,150 @@ +"""Flask webhook server for PayPalych postbacks.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import threading +from typing import Any, Dict, Optional + +from flask import Flask, jsonify, request +from werkzeug.serving import make_server + +from app.config import settings +from app.database.database import get_db +from app.services.pal24_service import Pal24Service, Pal24APIError +from app.services.payment_service import PaymentService + +logger = logging.getLogger(__name__) + + +def _normalize_payload() -> Dict[str, str]: + if request.is_json: + payload = request.get_json(silent=True) or {} + if isinstance(payload, dict): + return {k: str(v) for k, v in payload.items()} + logger.warning("Pal24 webhook JSON payload не является объектом: %s", payload) + return {} + + if request.form: + return {k: v for k, v in request.form.items()} + + try: + raw_body = request.data.decode("utf-8") + if raw_body: + payload = json.loads(raw_body) + if isinstance(payload, dict): + return {k: str(v) for k, v in payload.items()} + except json.JSONDecodeError: + logger.debug("Pal24 webhook body не удалось распарсить как JSON") + + return {} + + +def create_pal24_flask_app(payment_service: PaymentService) -> Flask: + pal24_service = Pal24Service() + app = Flask(__name__) + + @app.route(settings.PAL24_WEBHOOK_PATH, methods=["POST"]) + def pal24_webhook() -> tuple: + if not pal24_service.is_configured: + logger.error("Pal24 webhook получен, но сервис не настроен") + return jsonify({"status": "error", "reason": "service_not_configured"}), 503 + + payload = _normalize_payload() + if not payload: + logger.warning("Пустой Pal24 webhook") + return jsonify({"status": "error", "reason": "empty_payload"}), 400 + + try: + parsed_payload = pal24_service.parse_postback(payload) + except Pal24APIError as error: + logger.error("Ошибка валидации Pal24 webhook: %s", error) + return jsonify({"status": "error", "reason": str(error)}), 400 + + async def process() -> bool: + async for db in get_db(): + try: + return await payment_service.process_pal24_postback(db, parsed_payload) + finally: + await db.close() + + try: + processed = asyncio.run(process()) + except Exception as error: # pragma: no cover - defensive + logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error) + return jsonify({"status": "error", "reason": "internal_error"}), 500 + + if processed: + return jsonify({"status": "ok"}), 200 + return jsonify({"status": "error", "reason": "not_processed"}), 400 + + @app.route(settings.PAL24_WEBHOOK_PATH, methods=["GET"]) + def pal24_health() -> tuple: + return jsonify({ + "status": "ok", + "service": "pal24_webhook", + "enabled": settings.is_pal24_enabled(), + }), 200 + + @app.route("/pal24/health", methods=["GET"]) + def pal24_additional_health() -> tuple: + return jsonify({ + "status": "ok", + "service": "pal24_webhook", + "path": settings.PAL24_WEBHOOK_PATH, + }), 200 + + return app + + +class Pal24WebhookServer: + """Threaded Flask server for Pal24 postbacks.""" + + def __init__(self, payment_service: PaymentService) -> None: + self.app = create_pal24_flask_app(payment_service) + self._server: Optional[Any] = None + self._thread: Optional[threading.Thread] = None + + def start(self) -> None: + if self._server: + logger.warning("Pal24 webhook server уже запущен") + return + + self._server = make_server( + host="0.0.0.0", + port=settings.PAL24_WEBHOOK_PORT, + app=self.app, + threaded=True, + ) + + def _serve() -> None: + logger.info( + "Pal24 webhook сервер запущен на %s:%s%s", + "0.0.0.0", + settings.PAL24_WEBHOOK_PORT, + settings.PAL24_WEBHOOK_PATH, + ) + self._server.serve_forever() + + self._thread = threading.Thread(target=_serve, daemon=True) + self._thread.start() + + def stop(self) -> None: + if self._server: + logger.info("Останавливаем Pal24 webhook сервер") + self._server.shutdown() + self._server = None + + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=5) + self._thread = None + + +async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24WebhookServer: + server = Pal24WebhookServer(payment_service) + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, server.start) + return server + diff --git a/app/handlers/balance.py b/app/handlers/balance.py index dc875340..6ed27777 100644 --- a/app/handlers/balance.py +++ b/app/handlers/balance.py @@ -371,6 +371,45 @@ async def start_mulenpay_payment( await callback.answer() +@error_handler +async def start_pal24_payment( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + texts = get_texts(db_user.language) + + if not settings.is_pal24_enabled(): + await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True) + return + + message_text = texts.t( + "PAL24_TOPUP_PROMPT", + ( + "💳 Оплата через PayPalych\n\n" + "Введите сумму для пополнения от 100 до 1 000 000 ₽.\n" + "Оплата проходит через защищенную платформу PayPalych." + ), + ) + + keyboard = get_back_keyboard(db_user.language) + + if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: + quick_amount_buttons = get_quick_amount_buttons(db_user.language) + if quick_amount_buttons: + keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard + + await callback.message.edit_text( + message_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + + await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data(payment_method="pal24") + await callback.answer() + + @error_handler async def start_tribute_payment( callback: types.CallbackQuery, @@ -570,6 +609,10 @@ async def process_topup_amount( from app.database.database import AsyncSessionLocal async with AsyncSessionLocal() as db: await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state) + elif payment_method == "pal24": + from app.database.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + await process_pal24_payment_amount(message, db_user, db, amount_kopeks, state) elif payment_method == "cryptobot": from app.database.database import AsyncSessionLocal async with AsyncSessionLocal() as db: @@ -921,6 +964,119 @@ async def process_mulenpay_payment_amount( await state.clear() +@error_handler +async def process_pal24_payment_amount( + message: types.Message, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + state: FSMContext, +): + texts = get_texts(db_user.language) + + if not settings.is_pal24_enabled(): + await message.answer("❌ Оплата через PayPalych временно недоступна") + return + + if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS: + min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100 + await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽") + return + + if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS: + max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100 + await message.answer(f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽".replace(',', ' ')) + return + + try: + payment_service = PaymentService(message.bot) + payment_result = await payment_service.create_pal24_payment( + db=db, + user_id=db_user.id, + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + language=db_user.language, + ) + + if not payment_result or not payment_result.get("link_url"): + await message.answer( + texts.t( + "PAL24_PAYMENT_ERROR", + "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + ) + ) + await state.clear() + return + + link_url = payment_result.get("link_url") + bill_id = payment_result.get("bill_id") + local_payment_id = payment_result.get("local_payment_id") + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("PAL24_PAY_BUTTON", "💳 Оплатить через PayPalych"), + url=link_url, + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), + callback_data=f"check_pal24_{local_payment_id}", + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], + ] + ) + + message_template = texts.t( + "PAL24_PAYMENT_INSTRUCTIONS", + ( + "💳 Оплата через PayPalych\n\n" + "💰 Сумма: {amount}\n" + "🆔 ID счета: {bill_id}\n\n" + "📱 Инструкция:\n" + "1. Нажмите кнопку ‘Оплатить через PayPalych’\n" + "2. Следуйте подсказкам платежной системы\n" + "3. Подтвердите перевод\n" + "4. Средства зачислятся автоматически\n\n" + "❓ Если возникнут проблемы, обратитесь в {support}" + ), + ) + + message_text = message_template.format( + amount=settings.format_price(amount_kopeks), + bill_id=bill_id, + support=settings.get_support_contact_display_html(), + ) + + await message.answer( + message_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + + await state.clear() + + logger.info( + "Создан PayPalych счет для пользователя %s: %s₽, ID: %s", + db_user.telegram_id, + amount_kopeks / 100, + bill_id, + ) + + except Exception as e: + logger.error(f"Ошибка создания PayPalych платежа: {e}") + await message.answer( + texts.t( + "PAL24_PAYMENT_ERROR", + "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + ) + ) + await state.clear() + + @error_handler async def check_yookassa_payment_status( callback: types.CallbackQuery, @@ -1033,6 +1189,59 @@ async def check_mulenpay_payment_status( await callback.answer("❌ Ошибка проверки статуса", show_alert=True) +@error_handler +async def check_pal24_payment_status( + callback: types.CallbackQuery, + db: AsyncSession, +): + try: + local_payment_id = int(callback.data.split('_')[-1]) + payment_service = PaymentService(callback.bot) + status_info = await payment_service.get_pal24_payment_status(db, local_payment_id) + + if not status_info: + await callback.answer("❌ Платеж не найден", show_alert=True) + return + + payment = status_info["payment"] + + status_labels = { + "NEW": ("⏳", "Ожидает оплаты"), + "PROCESS": ("⌛", "Обрабатывается"), + "SUCCESS": ("✅", "Оплачен"), + "FAIL": ("❌", "Отменен"), + "UNDERPAID": ("⚠️", "Недоплата"), + "OVERPAID": ("⚠️", "Переплата"), + } + + emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно")) + + message_lines = [ + "💳 Статус платежа PayPalych:\n\n", + f"🆔 ID счета: {payment.bill_id}\n", + f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n", + f"📊 Статус: {emoji} {status_text}\n", + f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n", + ] + + if payment.is_paid: + message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.") + elif payment.status in {"NEW", "PROCESS"}: + message_lines.append("\n⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.") + if payment.link_url: + message_lines.append(f"\n🔗 Ссылка на оплату: {payment.link_url}") + elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}: + message_lines.append( + f"\n❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}" + ) + + await callback.answer("".join(message_lines), show_alert=True) + + except Exception as e: + logger.error(f"Ошибка проверки статуса PayPalych: {e}") + await callback.answer("❌ Ошибка проверки статуса", show_alert=True) + + @error_handler async def start_cryptobot_payment( callback: types.CallbackQuery, @@ -1362,6 +1571,11 @@ def register_handlers(dp: Dispatcher): F.data == "topup_mulenpay" ) + dp.callback_query.register( + start_pal24_payment, + F.data == "topup_pal24" + ) + dp.callback_query.register( check_yookassa_payment_status, F.data.startswith("check_yookassa_") @@ -1402,6 +1616,11 @@ def register_handlers(dp: Dispatcher): F.data.startswith("check_mulenpay_") ) + dp.callback_query.register( + check_pal24_payment_status, + F.data.startswith("check_pal24_") + ) + dp.callback_query.register( handle_payment_methods_unavailable, F.data == "payment_methods_unavailable" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 7802b31e..a7362e81 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -657,6 +657,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN ) ]) + if settings.is_pal24_enabled(): + keyboard.append([ + InlineKeyboardButton( + text=texts.t("PAYMENT_CARD_PAL24", "💳 Банковская карта (PayPalych)"), + callback_data="topup_pal24" + ) + ]) + if settings.is_cryptobot_enabled(): keyboard.append([ InlineKeyboardButton( diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 59831d1e..1882e35b 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -353,6 +353,7 @@ class AdminNotificationService: 'yookassa': '💳 YooKassa (карта)', 'tribute': '💎 Tribute (карта)', 'mulenpay': '💳 Mulen Pay (карта)', + 'pal24': '💳 PayPalych (карта)', 'manual': '🛠️ Вручную (админ)', 'balance': '💰 С баланса' } diff --git a/app/services/pal24_service.py b/app/services/pal24_service.py new file mode 100644 index 00000000..a4f72047 --- /dev/null +++ b/app/services/pal24_service.py @@ -0,0 +1,116 @@ +"""High level integration with PayPalych API.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from decimal import Decimal +from typing import Any, Dict, Optional + +from app.config import settings +from app.external.pal24_client import Pal24Client, Pal24APIError + +logger = logging.getLogger(__name__) + + +class Pal24Service: + """Wrapper around :class:`Pal24Client` providing domain helpers.""" + + BILL_SUCCESS_STATES = {"SUCCESS", "OVERPAID"} + BILL_FAILED_STATES = {"FAIL", "CANCELLED"} + BILL_PENDING_STATES = {"NEW", "PROCESS", "UNDERPAID"} + + def __init__(self, client: Optional[Pal24Client] = None) -> None: + self.client = client or Pal24Client() + + @property + def is_configured(self) -> bool: + return self.client.is_configured and settings.is_pal24_enabled() + + async def create_bill( + self, + *, + amount_kopeks: int, + user_id: int, + order_id: str, + description: str, + ttl_seconds: Optional[int] = None, + custom_payload: Optional[Dict[str, Any]] = None, + payer_email: Optional[str] = None, + ) -> Dict[str, Any]: + if not self.is_configured: + raise Pal24APIError("Pal24 service is not configured") + + amount_decimal = Pal24Client.normalize_amount(amount_kopeks) + extra_payload: Dict[str, Any] = { + "custom": custom_payload or {}, + "ttl": ttl_seconds, + } + + if payer_email: + extra_payload["payer_email"] = payer_email + + filtered_payload = {k: v for k, v in extra_payload.items() if v not in (None, {})} + + logger.info( + "Создаем Pal24 счет: user_id=%s, order_id=%s, amount=%s, ttl=%s", + user_id, + order_id, + amount_decimal, + ttl_seconds, + ) + + response = await self.client.create_bill( + amount=amount_decimal, + shop_id=settings.PAL24_SHOP_ID, + order_id=order_id, + description=description, + type_="normal", + **filtered_payload, + ) + + logger.info("Pal24 счет создан: %s", response) + return response + + async def get_bill_status(self, bill_id: str) -> Dict[str, Any]: + logger.debug("Запрашиваем статус Pal24 счета %s", bill_id) + return await self.client.get_bill_status(bill_id) + + async def get_payment_status(self, payment_id: str) -> Dict[str, Any]: + logger.debug("Запрашиваем статус Pal24 платежа %s", payment_id) + return await self.client.get_payment_status(payment_id) + + @staticmethod + def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]: + required_fields = ["InvId", "OutSum", "Status", "SignatureValue"] + missing = [field for field in required_fields if field not in payload] + if missing: + raise Pal24APIError(f"Pal24 postback missing fields: {', '.join(missing)}") + + inv_id = str(payload["InvId"]) + out_sum = str(payload["OutSum"]) + signature = str(payload["SignatureValue"]) + + if not Pal24Client.verify_signature(out_sum, inv_id, signature): + raise Pal24APIError("Pal24 postback signature mismatch") + + logger.info( + "Получен Pal24 postback: InvId=%s, Status=%s, TrsId=%s", + inv_id, + payload.get("Status"), + payload.get("TrsId"), + ) + + return payload + + @staticmethod + def convert_to_kopeks(amount: str) -> int: + decimal_amount = Decimal(str(amount)) + return int((decimal_amount * Decimal("100")).quantize(Decimal("1"))) + + @staticmethod + def get_expiration(ttl_seconds: Optional[int]) -> Optional[datetime]: + if not ttl_seconds: + return None + return datetime.utcnow() + timedelta(seconds=ttl_seconds) + diff --git a/app/services/payment_service.py b/app/services/payment_service.py index a08992a8..a761ef5c 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -29,6 +29,7 @@ from app.services.subscription_checkout_service import ( should_offer_checkout_resume, ) from app.services.mulenpay_service import MulenPayService +from app.services.pal24_service import Pal24Service, Pal24APIError from app.database.crud.mulenpay import ( create_mulenpay_payment, get_mulenpay_payment_by_local_id, @@ -37,6 +38,14 @@ from app.database.crud.mulenpay import ( update_mulenpay_payment_status, link_mulenpay_payment_to_transaction, ) +from app.database.crud.pal24 import ( + create_pal24_payment, + get_pal24_payment_by_bill_id, + get_pal24_payment_by_id, + get_pal24_payment_by_order_id, + link_pal24_payment_to_transaction, + update_pal24_payment_status, +) logger = logging.getLogger(__name__) @@ -49,6 +58,7 @@ class PaymentService: self.stars_service = TelegramStarsService(bot) if bot else None self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None self.mulenpay_service = MulenPayService() if settings.is_mulenpay_enabled() else None + self.pal24_service = Pal24Service() if settings.is_pal24_enabled() else None async def build_topup_success_keyboard(self, user) -> InlineKeyboardMarkup: texts = get_texts(user.language if user else "ru") @@ -782,6 +792,109 @@ class PaymentService: logger.error(f"Ошибка создания MulenPay платежа: {e}") return None + async def create_pal24_payment( + self, + db: AsyncSession, + *, + user_id: int, + amount_kopeks: int, + description: str, + language: str, + ttl_seconds: Optional[int] = None, + payer_email: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + + if not self.pal24_service or not self.pal24_service.is_configured: + logger.error("Pal24 сервис не инициализирован") + return None + + if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS: + logger.warning( + "Сумма Pal24 меньше минимальной: %s < %s", + amount_kopeks, + settings.PAL24_MIN_AMOUNT_KOPEKS, + ) + return None + + if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS: + logger.warning( + "Сумма Pal24 больше максимальной: %s > %s", + amount_kopeks, + settings.PAL24_MAX_AMOUNT_KOPEKS, + ) + return None + + order_id = f"pal24_{user_id}_{uuid.uuid4().hex}" + + custom_payload = { + "user_id": user_id, + "amount_kopeks": amount_kopeks, + "language": language, + } + + try: + response = await self.pal24_service.create_bill( + amount_kopeks=amount_kopeks, + user_id=user_id, + order_id=order_id, + description=description, + ttl_seconds=ttl_seconds, + custom_payload=custom_payload, + payer_email=payer_email, + ) + except Pal24APIError as error: + logger.error("Ошибка Pal24 API при создании счета: %s", error) + return None + + if not response.get("success", True): + logger.error("Pal24 вернул ошибку при создании счета: %s", response) + return None + + bill_id = response.get("bill_id") + if not bill_id: + logger.error("Pal24 не вернул bill_id: %s", response) + return None + + link_url = response.get("link_url") + link_page_url = response.get("link_page_url") + + payment = await create_pal24_payment( + db, + user_id=user_id, + bill_id=bill_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + description=description, + status=response.get("status", "NEW"), + type_=response.get("type", "normal"), + currency=response.get("currency", "RUB"), + link_url=link_url, + link_page_url=link_page_url, + ttl=ttl_seconds, + metadata={ + "raw_response": response, + "language": language, + }, + ) + + payment_info = { + "bill_id": bill_id, + "order_id": order_id, + "link_url": link_url or link_page_url, + "link_page_url": link_page_url, + "local_payment_id": payment.id, + "amount_kopeks": amount_kopeks, + } + + logger.info( + "Создан Pal24 счет %s для пользователя %s на сумму %s", + bill_id, + user_id, + settings.format_price(amount_kopeks), + ) + + return payment_info + async def process_mulenpay_callback(self, db: AsyncSession, callback_data: dict) -> bool: try: uuid_value = callback_data.get("uuid") @@ -964,6 +1077,155 @@ class PaymentService: logger.error(f"Ошибка обработки MulenPay callback: {error}", exc_info=True) return False + async def process_pal24_postback(self, db: AsyncSession, payload: Dict[str, Any]) -> bool: + + if not self.pal24_service or not self.pal24_service.is_configured: + logger.error("Pal24 сервис не инициализирован") + return False + + try: + order_id_raw = payload.get("InvId") + order_id = str(order_id_raw) if order_id_raw is not None else None + if not order_id: + logger.error("Pal24 postback без InvId") + return False + + payment = await get_pal24_payment_by_order_id(db, order_id) + if not payment: + bill_id = payload.get("BillId") + if bill_id: + payment = await get_pal24_payment_by_bill_id(db, str(bill_id)) + + if not payment: + logger.error("Pal24 платеж не найден для order_id=%s", order_id) + return False + + if payment.transaction_id and payment.is_paid: + logger.info("Pal24 платеж %s уже обработан", payment.bill_id) + return True + + status = str(payload.get("Status", "UNKNOWN")).upper() + payment_id = payload.get("TrsId") + balance_amount = payload.get("BalanceAmount") + balance_currency = payload.get("BalanceCurrency") + payer_account = payload.get("AccountNumber") + payment_method = payload.get("AccountType") + + try: + amount_kopeks = Pal24Service.convert_to_kopeks(str(payload.get("OutSum"))) + except Exception: + logger.warning("Не удалось распарсить сумму Pal24, используем сохраненное значение") + amount_kopeks = payment.amount_kopeks + + if amount_kopeks != payment.amount_kopeks: + logger.warning( + "Несовпадение суммы Pal24: callback=%s, ожидаемо=%s", + amount_kopeks, + payment.amount_kopeks, + ) + + is_success = status in Pal24Service.BILL_SUCCESS_STATES + is_failed = status in Pal24Service.BILL_FAILED_STATES + + await update_pal24_payment_status( + db, + payment, + status=status, + is_active=not is_failed, + is_paid=is_success, + payment_id=str(payment_id) if payment_id else None, + payment_status=status, + payment_method=str(payment_method) if payment_method else None, + balance_amount=str(balance_amount) if balance_amount is not None else None, + balance_currency=str(balance_currency) if balance_currency is not None else None, + payer_account=str(payer_account) if payer_account is not None else None, + callback_payload=payload, + ) + + if not is_success: + logger.info( + "Получен Pal24 статус %s для платежа %s (успех=%s)", + status, + payment.bill_id, + is_success, + ) + return True + + user = await get_user_by_id(db, payment.user_id) + if not user: + logger.error("Пользователь %s не найден для Pal24 платежа", payment.user_id) + return False + + transaction = await create_transaction( + db=db, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + amount_kopeks=payment.amount_kopeks, + description=f"Пополнение через Pal24 ({payment_id})", + payment_method=PaymentMethod.PAL24, + external_id=str(payment_id) if payment_id else payment.bill_id, + is_completed=True, + ) + + await link_pal24_payment_to_transaction(db, payment, transaction.id) + + old_balance = user.balance_kopeks + user.balance_kopeks += payment.amount_kopeks + user.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(user) + + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup(db, user.id, payment.amount_kopeks, self.bot) + except Exception as referral_error: + logger.error("Ошибка обработки реферального пополнения Pal24: %s", referral_error) + + if self.bot: + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(self.bot) + await notification_service.send_balance_topup_notification( + db, + user, + transaction, + old_balance, + ) + except Exception as notify_error: + logger.error("Ошибка отправки админ уведомления Pal24: %s", notify_error) + + if self.bot: + try: + keyboard = await self.build_topup_success_keyboard(user) + await self.bot.send_message( + user.telegram_id, + ( + "✅ Пополнение успешно!\n\n" + f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n" + "🦊 Способ: PayPalych\n" + f"🆔 Транзакция: {transaction.id}\n\n" + "Баланс пополнен автоматически!" + ), + parse_mode="HTML", + reply_markup=keyboard, + ) + except Exception as user_notify_error: + logger.error("Ошибка отправки уведомления пользователю Pal24: %s", user_notify_error) + + logger.info( + "✅ Обработан Pal24 платеж %s для пользователя %s", + payment.bill_id, + payment.user_id, + ) + + return True + + except Exception as error: + logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True) + return False + @staticmethod def _map_mulenpay_status(status_code: Optional[int]) -> str: mapping = { @@ -1037,6 +1299,50 @@ class PaymentService: logger.error(f"Ошибка получения статуса MulenPay: {error}", exc_info=True) return None + async def get_pal24_payment_status( + self, + db: AsyncSession, + local_payment_id: int, + ) -> Optional[Dict[str, Any]]: + try: + payment = await get_pal24_payment_by_id(db, local_payment_id) + if not payment: + return None + + remote_status = None + remote_data = None + + if self.pal24_service and payment.bill_id: + try: + response = await self.pal24_service.get_bill_status(payment.bill_id) + remote_data = response + remote_status = ( + response.get("status") + or response.get("bill", {}).get("status") + ) + + if remote_status and remote_status != payment.status: + await update_pal24_payment_status( + db, + payment, + status=str(remote_status).upper(), + ) + payment = await get_pal24_payment_by_id(db, local_payment_id) + except Pal24APIError as error: + logger.error("Ошибка Pal24 API при получении статуса: %s", error) + + return { + "payment": payment, + "status": payment.status, + "is_paid": payment.is_paid, + "remote_status": remote_status, + "remote_data": remote_data, + } + + except Exception as error: + logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True) + return None + async def process_cryptobot_webhook(self, db: AsyncSession, webhook_data: dict) -> bool: try: from app.database.crud.cryptobot import ( diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py index 0bc6191f..83b704b1 100644 --- a/app/utils/payment_utils.py +++ b/app/utils/payment_utils.py @@ -45,6 +45,15 @@ def get_available_payment_methods() -> List[Dict[str, str]]: "callback": "topup_mulenpay" }) + if settings.is_pal24_enabled(): + methods.append({ + "id": "pal24", + "name": "Банковская карта", + "icon": "💳", + "description": "через PayPalych", + "callback": "topup_pal24" + }) + if settings.is_cryptobot_enabled(): methods.append({ "id": "cryptobot", @@ -123,6 +132,8 @@ def is_payment_method_available(method_id: str) -> bool: return settings.TRIBUTE_ENABLED elif method_id == "mulenpay": return settings.is_mulenpay_enabled() + elif method_id == "pal24": + return settings.is_pal24_enabled() elif method_id == "cryptobot": return settings.is_cryptobot_enabled() elif method_id == "support": @@ -139,6 +150,7 @@ def get_payment_method_status() -> Dict[str, bool]: "yookassa": settings.is_yookassa_enabled(), "tribute": settings.TRIBUTE_ENABLED, "mulenpay": settings.is_mulenpay_enabled(), + "pal24": settings.is_pal24_enabled(), "cryptobot": settings.is_cryptobot_enabled(), "support": True } @@ -156,6 +168,8 @@ def get_enabled_payment_methods_count() -> int: count += 1 if settings.is_mulenpay_enabled(): count += 1 + if settings.is_pal24_enabled(): + count += 1 if settings.is_cryptobot_enabled(): count += 1 return count \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index b3d62a39..a38f1744 100644 --- a/locales/en.json +++ b/locales/en.json @@ -57,6 +57,7 @@ "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable", "PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)", "PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)", + "PAYMENT_CARD_PAL24": "💳 Bank card (PayPalych)", "PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)", "PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)", "PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)", @@ -68,6 +69,10 @@ "MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.", "MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay", "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Mulen Pay payment\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 How to pay:\n1. Press ‘Pay with Mulen Pay’\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", + "PAL24_TOPUP_PROMPT": "💳 PayPalych payment\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed by the secure PayPalych platform.", + "PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.", + "PAL24_PAY_BUTTON": "💳 Pay with PayPalych", + "PAL24_PAYMENT_INSTRUCTIONS": "💳 PayPalych payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\n1. Press ‘Pay with PayPalych’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", "PENDING_CANCEL_BUTTON": "⌛ Cancel", "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀", "REFERRAL_ANALYTICS_BUTTON": "📊 Analytics", @@ -451,6 +456,8 @@ "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", "PAYMENT_METHOD_MULENPAY_NAME": "💳 Bank card (Mulen Pay)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay", + "PAYMENT_METHOD_PAL24_NAME": "💳 Bank card (PayPalych)", + "PAYMENT_METHOD_PAL24_DESCRIPTION": "via PayPalych", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", diff --git a/locales/ru.json b/locales/ru.json index 6c84da2c..eeb76649 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -215,6 +215,7 @@ "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны", "PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)", "PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)", + "PAYMENT_CARD_PAL24": "💳 Банковская карта (PayPalych)", "PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)", "PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)", "PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)", @@ -226,6 +227,10 @@ "MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.", "MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay", "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Оплата через Mulen Pay\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", + "PAL24_TOPUP_PROMPT": "💳 Оплата через PayPalych\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через защищенную платформу PayPalych.", + "PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + "PAL24_PAY_BUTTON": "💳 Оплатить через PayPalych", + "PAL24_PAYMENT_INSTRUCTIONS": "💳 Оплата через PayPalych\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через PayPalych’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", "PENDING_CANCEL_BUTTON": "⌛ Отмена", "PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}", "PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}", @@ -451,6 +456,8 @@ "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банковская карта (Mulen Pay)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay", + "PAYMENT_METHOD_PAL24_NAME": "💳 Банковская карта (PayPalych)", + "PAYMENT_METHOD_PAL24_DESCRIPTION": "через PayPalych", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", diff --git a/main.py b/main.py index 0ba40b99..4be13cde 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,7 @@ from app.services.payment_service import PaymentService from app.services.version_service import version_service from app.external.webhook_server import WebhookServer from app.external.yookassa_webhook import start_yookassa_webhook_server +from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookServer from app.database.universal_migration import run_universal_migration from app.services.backup_service import backup_service from app.localization.loader import ensure_locale_templates @@ -55,6 +56,7 @@ async def main(): webhook_server = None yookassa_server_task = None + pal24_server: Pal24WebhookServer | None = None monitoring_task = None maintenance_task = None version_check_task = None @@ -140,7 +142,13 @@ async def main(): ) else: logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается") - + + if settings.is_pal24_enabled(): + logger.info("💳 Запуск PayPalych webhook сервера...") + pal24_server = await start_pal24_webhook_server(payment_service) + else: + logger.info("ℹ️ PayPalych отключен, webhook сервер не запускается") + logger.info("📊 Запуск службы мониторинга...") monitoring_task = asyncio.create_task(monitoring_service.start_monitoring()) @@ -172,6 +180,10 @@ async def main(): logger.info(f" CryptoBot: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}") if settings.is_yookassa_enabled(): logger.info(f" YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}") + if settings.is_pal24_enabled(): + logger.info( + f" PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}" + ) logger.info("📄 Активные фоновые сервисы:") logger.info(f" Мониторинг: {'Включен' if monitoring_task else 'Отключен'}") logger.info(f" Техработы: {'Включен' if maintenance_task else 'Отключен'}") @@ -243,6 +255,10 @@ async def main(): await monitoring_task except asyncio.CancelledError: pass + + if pal24_server: + logger.info("ℹ️ Остановка PayPalych webhook сервера...") + await asyncio.get_running_loop().run_in_executor(None, pal24_server.stop) if maintenance_task and not maintenance_task.done(): logger.info("ℹ️ Остановка службы техработ...") diff --git a/requirements.txt b/requirements.txt index 95294b91..53f98559 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,3 +32,6 @@ qrcode[pil]==7.4.2 packaging==23.2 aiofiles==23.2.1 + +# Вебхуки PayPalych (Flask) +Flask==3.1.0 From 5dcb37032eb6f716bc1eaf7670f030a620991205 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 03:08:33 +0300 Subject: [PATCH 013/146] Fix PayPalych webhook to reuse main event loop --- app/external/pal24_webhook.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/app/external/pal24_webhook.py b/app/external/pal24_webhook.py index 6f815bd6..28b87ea2 100644 --- a/app/external/pal24_webhook.py +++ b/app/external/pal24_webhook.py @@ -6,6 +6,7 @@ import asyncio import json import logging import threading +from asyncio import AbstractEventLoop from typing import Any, Dict, Optional from flask import Flask, jsonify, request @@ -42,7 +43,10 @@ def _normalize_payload() -> Dict[str, str]: return {} -def create_pal24_flask_app(payment_service: PaymentService) -> Flask: +def create_pal24_flask_app( + payment_service: PaymentService, + loop: AbstractEventLoop, +) -> Flask: pal24_service = Pal24Service() app = Flask(__name__) @@ -71,7 +75,8 @@ def create_pal24_flask_app(payment_service: PaymentService) -> Flask: await db.close() try: - processed = asyncio.run(process()) + future = asyncio.run_coroutine_threadsafe(process(), loop) + processed = future.result() except Exception as error: # pragma: no cover - defensive logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error) return jsonify({"status": "error", "reason": "internal_error"}), 500 @@ -102,8 +107,8 @@ def create_pal24_flask_app(payment_service: PaymentService) -> Flask: class Pal24WebhookServer: """Threaded Flask server for Pal24 postbacks.""" - def __init__(self, payment_service: PaymentService) -> None: - self.app = create_pal24_flask_app(payment_service) + def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None: + self.app = create_pal24_flask_app(payment_service, loop) self._server: Optional[Any] = None self._thread: Optional[threading.Thread] = None @@ -143,8 +148,8 @@ class Pal24WebhookServer: async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24WebhookServer: - server = Pal24WebhookServer(payment_service) loop = asyncio.get_running_loop() + server = Pal24WebhookServer(payment_service, loop) await loop.run_in_executor(None, server.start) return server From 4f40cb8862d0305d24324ec66985901848bdb153 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 03:31:46 +0300 Subject: [PATCH 014/146] Fix Pal24 webhook DB session handling --- app/external/pal24_webhook.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/external/pal24_webhook.py b/app/external/pal24_webhook.py index 28b87ea2..c511d102 100644 --- a/app/external/pal24_webhook.py +++ b/app/external/pal24_webhook.py @@ -69,10 +69,7 @@ def create_pal24_flask_app( async def process() -> bool: async for db in get_db(): - try: - return await payment_service.process_pal24_postback(db, parsed_payload) - finally: - await db.close() + return await payment_service.process_pal24_postback(db, parsed_payload) try: future = asyncio.run_coroutine_threadsafe(process(), loop) From b06da932cdbdd28c17be879d1700a208baac40ae Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 03:49:01 +0300 Subject: [PATCH 015/146] Ensure Pal24 webhook closes DB session --- app/external/pal24_webhook.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/external/pal24_webhook.py b/app/external/pal24_webhook.py index c511d102..28b87ea2 100644 --- a/app/external/pal24_webhook.py +++ b/app/external/pal24_webhook.py @@ -69,7 +69,10 @@ def create_pal24_flask_app( async def process() -> bool: async for db in get_db(): - return await payment_service.process_pal24_postback(db, parsed_payload) + try: + return await payment_service.process_pal24_postback(db, parsed_payload) + finally: + await db.close() try: future = asyncio.run_coroutine_threadsafe(process(), loop) From f658cfcd0335114ceb7123463c3bbd2e043f09b2 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 03:55:54 +0300 Subject: [PATCH 016/146] Update .env.example --- .env.example | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.env.example b/.env.example index 6fadd322..42b9ff82 100644 --- a/.env.example +++ b/.env.example @@ -246,6 +246,19 @@ MULENPAY_VAT_CODE=0 MULENPAY_PAYMENT_SUBJECT=4 MULENPAY_PAYMENT_MODE=4 +# PAYPALYCH / PAL24 +PAL24_ENABLED=false +PAL24_API_TOKEN= +PAL24_SHOP_ID= +PAL24_SIGNATURE_TOKEN= +PAL24_BASE_URL=https://pal24.pro/api/v1/ +PAL24_WEBHOOK_PATH=/pal24-webhook +PAL24_WEBHOOK_PORT=8084 +PAL24_PAYMENT_DESCRIPTION="Пополнение баланса" +PAL24_MIN_AMOUNT_KOPEKS=10000 +PAL24_MAX_AMOUNT_KOPEKS=100000000 +PAL24_REQUEST_TIMEOUT=30 + # ===== ИНТЕРФЕЙС И UX ===== # Включить логотип для всех сообщений (true - с изображением, false - только текст) From b23490586040d31c34720dce84c356161e875612 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 04:04:20 +0300 Subject: [PATCH 017/146] Fix admin user deletion and improve backups --- app/services/backup_service.py | 178 ++++++++++++++++++++------------- app/services/user_service.py | 68 ++++++++++++- 2 files changed, 175 insertions(+), 71 deletions(-) diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 8c5f6b8d..a1fbee35 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -21,7 +21,9 @@ from app.database.models import ( ReferralEarning, Squad, ServiceRule, SystemSetting, MonitoringLog, SubscriptionConversion, SentNotification, BroadcastHistory, ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment, - CryptoBotPayment, WelcomeText, Base + CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign, + AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage, + MulenPayPayment, Pal24Payment ) logger = logging.getLogger(__name__) @@ -61,29 +63,34 @@ class BackupService: self._settings = self._load_settings() self.backup_models_ordered = [ - ServiceRule, SystemSetting, + ServiceRule, Squad, - PromoCode, ServerSquad, - - User, - - WelcomeText, + PromoGroup, + User, + PromoCode, + WelcomeText, + UserMessage, Subscription, + SubscriptionServer, + SubscriptionConversion, Transaction, YooKassaPayment, CryptoBotPayment, + MulenPayPayment, + Pal24Payment, PromoCodeUse, ReferralEarning, - SubscriptionConversion, + SentNotification, BroadcastHistory, - UserMessage, - - SentNotification, - SubscriptionServer, + AdvertisingCampaign, + AdvertisingCampaignRegistration, + Ticket, + TicketMessage, + SupportAuditLog, ] - + if self._settings.include_logs: self.backup_models_ordered.append(MonitoringLog) @@ -329,60 +336,47 @@ class BackupService: await self._clear_database_tables(db) models_by_table = {model.__tablename__: model for model in self.backup_models_ordered} - - await self._restore_users_without_referrals(db, backup_data, models_by_table) - - for model in self.backup_models_ordered: - table_name = model.__tablename__ - - if table_name == "users": + + pre_restore_tables = {"promo_groups"} + for table_name in pre_restore_tables: + model = models_by_table.get(table_name) + if not model: continue - + records = backup_data.get(table_name, []) if not records: continue - + logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)") - - for record_data in records: - try: - processed_data = self._process_record_data(record_data, model, table_name) - - primary_key_col = self._get_primary_key_column(model) - - if primary_key_col and primary_key_col in processed_data: - existing_record = await db.execute( - select(model).where( - getattr(model, primary_key_col) == processed_data[primary_key_col] - ) - ) - existing = existing_record.scalar_one_or_none() - - if existing and not clear_existing: - for key, value in processed_data.items(): - if key != primary_key_col: - setattr(existing, key, value) - logger.debug(f"Обновлена существующая запись {primary_key_col}={processed_data[primary_key_col]} в {table_name}") - else: - instance = model(**processed_data) - db.add(instance) - else: - instance = model(**processed_data) - db.add(instance) - - restored_records += 1 - - except Exception as e: - logger.error(f"Ошибка восстановления записи в {table_name}: {e}") - logger.error(f"Проблемные данные: {record_data}") - await db.rollback() - raise e - - restored_tables += 1 - logger.info(f"✅ Таблица {table_name} восстановлена") - + restored = await self._restore_table_records(db, model, table_name, records, clear_existing) + restored_records += restored + + if restored: + restored_tables += 1 + logger.info(f"✅ Таблица {table_name} восстановлена") + + await self._restore_users_without_referrals(db, backup_data, models_by_table) + + for model in self.backup_models_ordered: + table_name = model.__tablename__ + + if table_name == "users" or table_name in pre_restore_tables: + continue + + records = backup_data.get(table_name, []) + if not records: + continue + + logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)") + restored = await self._restore_table_records(db, model, table_name, records, clear_existing) + restored_records += restored + + if restored: + restored_tables += 1 + logger.info(f"✅ Таблица {table_name} восстановлена") + await self._update_user_referrals(db, backup_data) - + await db.commit() break @@ -549,14 +543,64 @@ class BackupService: return col.name return None + async def _restore_table_records( + self, + db: AsyncSession, + model, + table_name: str, + records: List[Dict[str, Any]], + clear_existing: bool + ) -> int: + restored_count = 0 + + for record_data in records: + try: + processed_data = self._process_record_data(record_data, model, table_name) + + primary_key_col = self._get_primary_key_column(model) + + if primary_key_col and primary_key_col in processed_data: + existing_record = await db.execute( + select(model).where( + getattr(model, primary_key_col) == processed_data[primary_key_col] + ) + ) + existing = existing_record.scalar_one_or_none() + + if existing and not clear_existing: + for key, value in processed_data.items(): + if key != primary_key_col: + setattr(existing, key, value) + else: + instance = model(**processed_data) + db.add(instance) + else: + instance = model(**processed_data) + db.add(instance) + + restored_count += 1 + + except Exception as e: + logger.error(f"Ошибка восстановления записи в {table_name}: {e}") + logger.error(f"Проблемные данные: {record_data}") + await db.rollback() + raise e + + return restored_count + async def _clear_database_tables(self, db: AsyncSession): tables_order = [ - "subscription_servers", "sent_notifications", - "user_messages", "broadcast_history", "subscription_conversions", - "referral_earnings", "promocode_uses", "transactions", - "yookassa_payments", "cryptobot_payments", "welcome_texts", - "subscriptions", "users", "promocodes", "server_squads", - "squads", "service_rules", "system_settings", "monitoring_logs" + "ticket_messages", "tickets", "support_audit_logs", + "advertising_campaign_registrations", "advertising_campaigns", + "subscription_servers", "sent_notifications", + "user_messages", "broadcast_history", "subscription_conversions", + "referral_earnings", "promocode_uses", + "yookassa_payments", "cryptobot_payments", + "mulenpay_payments", "pal24_payments", + "transactions", "welcome_texts", "subscriptions", + "promocodes", "users", "promo_groups", + "server_squads", "squads", "service_rules", + "system_settings", "monitoring_logs" ] for table_name in tables_order: diff --git a/app/services/user_service.py b/app/services/user_service.py index dedd672a..5b7f57b4 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -17,7 +17,8 @@ from app.database.models import ( User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse, ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory, CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText, - SentNotification, PromoGroup + SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, + AdvertisingCampaign ) from app.config import settings @@ -493,7 +494,7 @@ class UserService: select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id) ) cryptobot_payments = cryptobot_result.scalars().all() - + if cryptobot_payments: logger.info(f"🔄 Удаляем {len(cryptobot_payments)} CryptoBot платежей") await db.execute( @@ -508,7 +509,49 @@ class UserService: await db.flush() except Exception as e: logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}") - + + try: + mulenpay_result = await db.execute( + select(MulenPayPayment).where(MulenPayPayment.user_id == user_id) + ) + mulenpay_payments = mulenpay_result.scalars().all() + + if mulenpay_payments: + logger.info(f"🔄 Удаляем {len(mulenpay_payments)} MulenPay платежей") + await db.execute( + update(MulenPayPayment) + .where(MulenPayPayment.user_id == user_id) + .values(transaction_id=None) + ) + await db.flush() + await db.execute( + delete(MulenPayPayment).where(MulenPayPayment.user_id == user_id) + ) + await db.flush() + except Exception as e: + logger.error(f"❌ Ошибка удаления MulenPay платежей: {e}") + + try: + pal24_result = await db.execute( + select(Pal24Payment).where(Pal24Payment.user_id == user_id) + ) + pal24_payments = pal24_result.scalars().all() + + if pal24_payments: + logger.info(f"🔄 Удаляем {len(pal24_payments)} Pal24 платежей") + await db.execute( + update(Pal24Payment) + .where(Pal24Payment.user_id == user_id) + .values(transaction_id=None) + ) + await db.flush() + await db.execute( + delete(Pal24Payment).where(Pal24Payment.user_id == user_id) + ) + await db.flush() + except Exception as e: + logger.error(f"❌ Ошибка удаления Pal24 платежей: {e}") + try: transactions_result = await db.execute( select(Transaction).where(Transaction.user_id == user_id) @@ -589,7 +632,7 @@ class UserService: select(BroadcastHistory).where(BroadcastHistory.admin_id == user_id) ) broadcast_history = broadcast_history_result.scalars().all() - + if broadcast_history: logger.info(f"🔄 Удаляем {len(broadcast_history)} записей истории рассылок") await db.execute( @@ -598,6 +641,23 @@ class UserService: await db.flush() except Exception as e: logger.error(f"❌ Ошибка удаления истории рассылок: {e}") + + try: + campaigns_result = await db.execute( + select(AdvertisingCampaign).where(AdvertisingCampaign.created_by == user_id) + ) + campaigns = campaigns_result.scalars().all() + + if campaigns: + logger.info(f"🔄 Очищаем создателя у {len(campaigns)} рекламных кампаний") + await db.execute( + update(AdvertisingCampaign) + .where(AdvertisingCampaign.created_by == user_id) + .values(created_by=None) + ) + await db.flush() + except Exception as e: + logger.error(f"❌ Ошибка обновления рекламных кампаний: {e}") try: if user.subscription: From 9663eb493173c4e71dd0bbef3e0dfa47e6109014 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 04:24:50 +0300 Subject: [PATCH 018/146] Add mini app server status mode --- .env.example | 4 ++-- README.md | 8 ++++---- app/config.py | 13 +++++++++++-- app/keyboards/inline.py | 9 +++++++++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 6fadd322..2a801dfb 100644 --- a/.env.example +++ b/.env.example @@ -282,9 +282,9 @@ MONITORING_LOGS_RETENTION_DAYS=30 NOTIFICATION_CACHE_HOURS=24 # ===== СТАТУС СЕРВЕРОВ ===== -# Режимы: disabled, external_link, xray +# Режимы: disabled, external_link, external_link_miniapp, xray SERVER_STATUS_MODE=disabled -# Ссылка на внешний мониторинг (для режима external_link) +# Ссылка на внешний мониторинг (для режимов external_link и external_link_miniapp) SERVER_STATUS_EXTERNAL_URL= # URL метрик XrayChecker (для режима xray) SERVER_STATUS_METRICS_URL= diff --git a/README.md b/README.md index b73295b6..7f65c08a 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,8 @@ docker compose logs | Переменная | Описание | Пример | |------------|----------|--------| -| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link` (просто ссылка) или `xray` (интеграция с XrayChecker). | `xray` | -| `SERVER_STATUS_EXTERNAL_URL` | Прямая ссылка на внешний мониторинг (используется в режиме `external_link`). | `https://status.example.com` | +| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link` (открывает ссылку в браузере), `external_link_miniapp` (открывает ссылку во встроенном мини-приложении Telegram) или `xray` (интеграция с XrayChecker). | `xray` | +| `SERVER_STATUS_EXTERNAL_URL` | Прямая ссылка на внешний мониторинг (используется в режимах `external_link` и `external_link_miniapp`). | `https://status.example.com` | | `SERVER_STATUS_METRICS_URL` | URL страницы метрик XrayChecker (Prometheus формат). | `https://sub.example.com/metrics` | | `SERVER_STATUS_METRICS_USERNAME` / `SERVER_STATUS_METRICS_PASSWORD` | Данные Basic Auth, если страница метрик защищена паролем. | `status` / `secret` | | `SERVER_STATUS_ITEMS_PER_PAGE` | Количество серверов, показываемых на одной странице в режиме интеграции. | `10` | @@ -541,9 +541,9 @@ MONITORING_LOGS_RETENTION_DAYS=30 NOTIFICATION_CACHE_HOURS=24 # ===== СТАТУС СЕРВЕРОВ ===== -# Режимы: disabled, external_link, xray +# Режимы: disabled, external_link, external_link_miniapp, xray SERVER_STATUS_MODE=disabled -# Ссылка на внешний мониторинг (для режима external_link) +# Ссылка на внешний мониторинг (для режимов external_link и external_link_miniapp) SERVER_STATUS_EXTERNAL_URL= # URL метрик XrayChecker (для режима xray) SERVER_STATUS_METRICS_URL= diff --git a/app/config.py b/app/config.py index 6c038b5a..8c8e6f17 100644 --- a/app/config.py +++ b/app/config.py @@ -250,6 +250,13 @@ class Settings(BaseSettings): "link": "external_link", "url": "external_link", "external_link": "external_link", + "miniapp": "external_link_miniapp", + "mini_app": "external_link_miniapp", + "mini-app": "external_link_miniapp", + "webapp": "external_link_miniapp", + "web_app": "external_link_miniapp", + "web-app": "external_link_miniapp", + "external_link_miniapp": "external_link_miniapp", "xray": "xray", "xraychecker": "xray", "xray_metrics": "xray", @@ -257,8 +264,10 @@ class Settings(BaseSettings): } mode = aliases.get(normalized, normalized) - if mode not in {"disabled", "external_link", "xray"}: - raise ValueError("SERVER_STATUS_MODE must be one of: disabled, external_link, xray") + if mode not in {"disabled", "external_link", "external_link_miniapp", "xray"}: + raise ValueError( + "SERVER_STATUS_MODE must be one of: disabled, external_link, external_link_miniapp, xray" + ) return mode @field_validator('SERVER_STATUS_ITEMS_PER_PAGE', mode='before') diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index a7362e81..0b4b5ab2 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -172,6 +172,15 @@ def get_main_menu_keyboard( keyboard.append([ InlineKeyboardButton(text=server_status_text, url=status_url) ]) + elif server_status_mode == "external_link_miniapp": + status_url = settings.get_server_status_external_url() + if status_url: + keyboard.append([ + InlineKeyboardButton( + text=server_status_text, + web_app=types.WebAppInfo(url=status_url), + ) + ]) elif server_status_mode == "xray": keyboard.append([ InlineKeyboardButton(text=server_status_text, callback_data="menu_server_status") From 084363b3d6a816b174286cf4e458bf33e09e1eb8 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 05:04:19 +0300 Subject: [PATCH 019/146] feat: add period-based discounts for promo groups --- app/database/crud/promo_group.py | 31 +- app/database/crud/subscription.py | 13 +- app/database/models.py | 60 +++- app/database/universal_migration.py | 47 ++- app/handlers/admin/promo_groups.py | 276 ++++++++++++++++-- app/handlers/subscription.py | 167 +++++++++-- app/services/subscription_service.py | 74 ++++- app/states.py | 2 + locales/en.json | 4 + locales/ru.json | 4 + ...f9_add_period_discounts_to_promo_groups.py | 29 ++ 11 files changed, 623 insertions(+), 84 deletions(-) create mode 100644 migrations/alembic/versions/4b6b0f58c8f9_add_period_discounts_to_promo_groups.py diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 3845f531..d63c3107 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -7,6 +7,24 @@ from sqlalchemy.orm import selectinload from app.database.models import PromoGroup, User + +def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]: + if not period_discounts: + return {} + + normalized: Dict[int, int] = {} + + for key, value in period_discounts.items(): + try: + period = int(key) + percent = int(value) + except (TypeError, ValueError): + continue + + normalized[period] = max(0, min(100, percent)) + + return normalized + logger = logging.getLogger(__name__) @@ -40,12 +58,16 @@ async def create_promo_group( server_discount_percent: int, traffic_discount_percent: int, device_discount_percent: int, + period_discounts: Optional[Dict[int, int]] = None, ) -> PromoGroup: + normalized_period_discounts = _normalize_period_discounts(period_discounts) + promo_group = PromoGroup( name=name.strip(), server_discount_percent=max(0, min(100, server_discount_percent)), traffic_discount_percent=max(0, min(100, traffic_discount_percent)), device_discount_percent=max(0, min(100, device_discount_percent)), + period_discounts=normalized_period_discounts or None, is_default=False, ) @@ -54,11 +76,12 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%)", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s)", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, + normalized_period_discounts, ) return promo_group @@ -72,6 +95,7 @@ async def update_promo_group( server_discount_percent: Optional[int] = None, traffic_discount_percent: Optional[int] = None, device_discount_percent: Optional[int] = None, + period_discounts: Optional[Dict[int, int]] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -81,6 +105,9 @@ async def update_promo_group( group.traffic_discount_percent = max(0, min(100, traffic_discount_percent)) if device_discount_percent is not None: group.device_discount_percent = max(0, min(100, device_discount_percent)) + if period_discounts is not None: + normalized_period_discounts = _normalize_period_discounts(period_discounts) + group.period_discounts = normalized_period_discounts or None await db.commit() await db.refresh(group) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 8898b220..051c2369 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -531,7 +531,15 @@ async def calculate_subscription_total_cost( months_in_period = calculate_months_from_days(period_days) - base_price = PERIOD_PRICES.get(period_days, 0) + base_price_original = PERIOD_PRICES.get(period_days, 0) + period_discount_percent = _get_discount_percent( + user, + promo_group, + "period", + period_days=period_days, + ) + base_discount_total = base_price_original * period_discount_percent // 100 + base_price = base_price_original - base_discount_total promo_group = promo_group or (user.promo_group if user else None) @@ -577,6 +585,9 @@ async def calculate_subscription_total_cost( details = { 'base_price': base_price, + 'base_price_original': base_price_original, + 'base_discount_percent': period_discount_percent, + 'base_discount_total': base_discount_total, 'traffic_price_per_month': traffic_price_per_month, 'traffic_discount_percent': traffic_discount_percent, 'traffic_discount_total': total_traffic_discount, diff --git a/app/database/models.py b/app/database/models.py index 1c67713f..9cdeaa86 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from typing import Optional, List +from typing import Optional, List, Dict from enum import Enum from sqlalchemy import ( @@ -270,13 +270,59 @@ class PromoGroup(Base): server_discount_percent = Column(Integer, nullable=False, default=0) traffic_discount_percent = Column(Integer, nullable=False, default=0) device_discount_percent = Column(Integer, nullable=False, default=0) + period_discounts = Column(JSON, nullable=True, default=dict) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) users = relationship("User", back_populates="promo_group") + def _get_period_discounts_map(self) -> Dict[int, int]: + raw_discounts = self.period_discounts or {} + + if isinstance(raw_discounts, dict): + items = raw_discounts.items() + else: + items = [] + + normalized: Dict[int, int] = {} + + for key, value in items: + try: + period = int(key) + percent = int(value) + except (TypeError, ValueError): + continue + + normalized[period] = max(0, min(100, percent)) + + return normalized + + def _get_period_discount(self, period_days: Optional[int]) -> int: + if not period_days: + return 0 + + discounts = self._get_period_discounts_map() + + if period_days in discounts: + return discounts[period_days] + + if self.is_default: + try: + from app.config import settings + + if settings.is_base_promo_group_period_discount_enabled(): + config_discounts = settings.get_base_promo_group_period_discounts() + return config_discounts.get(period_days, 0) + except Exception: + return 0 + + return 0 + def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int: + if category == "period": + return max(0, min(100, self._get_period_discount(period_days))) + mapping = { "servers": self.server_discount_percent, "traffic": self.traffic_discount_percent, @@ -284,18 +330,6 @@ class PromoGroup(Base): } percent = mapping.get(category, 0) - if self.is_default and period_days is not None: - try: - from app.config import settings - - if settings.is_base_promo_group_period_discount_enabled(): - discounts = settings.get_base_promo_group_period_discounts() - if period_days in discounts: - period_discount = discounts[period_days] - percent = period_discount - except Exception: - pass - return max(0, min(100, percent)) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 6c3c7853..ce270f13 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -681,6 +681,46 @@ async def ensure_promo_groups_setup(): f"Не удалось добавить уникальное ограничение uq_promo_groups_name: {e}" ) + period_discounts_column_exists = await check_column_exists( + "promo_groups", "period_discounts" + ) + + if not period_discounts_column_exists: + if db_type == "sqlite": + await conn.execute( + text("ALTER TABLE promo_groups ADD COLUMN period_discounts JSON") + ) + await conn.execute( + text("UPDATE promo_groups SET period_discounts = '{}' WHERE period_discounts IS NULL") + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN period_discounts JSONB" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET period_discounts = '{}'::jsonb WHERE period_discounts IS NULL" + ) + ) + elif db_type == "mysql": + await conn.execute( + text("ALTER TABLE promo_groups ADD COLUMN period_discounts JSON") + ) + await conn.execute( + text( + "UPDATE promo_groups SET period_discounts = JSON_OBJECT() WHERE period_discounts IS NULL" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.period_discounts: {db_type}" + ) + return False + + logger.info("Добавлена колонка promo_groups.period_discounts") + column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -1543,7 +1583,8 @@ async def check_migration_status(): "subscription_duplicates": False, "subscription_conversions_table": False, "promo_groups_table": False, - "users_promo_group_column": False + "users_promo_group_column": False, + "promo_groups_period_discounts_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1556,6 +1597,7 @@ async def check_migration_status(): status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') + status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -1587,7 +1629,8 @@ async def check_migration_status(): "subscription_conversions_table": "Таблица конверсий подписок", "subscription_duplicates": "Отсутствие дубликатов подписок", "promo_groups_table": "Таблица промо-групп", - "users_promo_group_column": "Колонка promo_group_id у пользователей" + "users_promo_group_column": "Колонка promo_group_id у пользователей", + "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index ef927f94..0546550e 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -1,10 +1,12 @@ import logging -from typing import Optional +import logging +from typing import Dict, Optional from aiogram import Dispatcher, types, F from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.database.crud.promo_group import ( get_promo_groups_with_counts, get_promo_group_by_id, @@ -22,7 +24,7 @@ from app.keyboards.admin import ( get_admin_pagination_keyboard, get_confirmation_keyboard, ) - +from app.utils.pricing_utils import format_period_description logger = logging.getLogger(__name__) @@ -37,6 +39,128 @@ def _format_discount_line(texts, group) -> str: ) +def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: + if not raw or not isinstance(raw, dict): + return {} + + normalized: Dict[int, int] = {} + + for key, value in raw.items(): + try: + period = int(key) + percent = int(value) + except (TypeError, ValueError): + continue + + normalized[period] = max(0, min(100, percent)) + + return normalized + + +def _collect_period_discounts(group: PromoGroup) -> Dict[int, int]: + discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) + + if discounts: + return dict(sorted(discounts.items())) + + if group.is_default and settings.is_base_promo_group_period_discount_enabled(): + try: + base_discounts = settings.get_base_promo_group_period_discounts() + normalized = _normalize_periods_dict(base_discounts) + return dict(sorted(normalized.items())) + except Exception: + return {} + + return {} + + +def _format_period_discounts_lines(texts, group: PromoGroup, language: str) -> list: + discounts = _collect_period_discounts(group) + + if not discounts: + return [] + + header = texts.t( + "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER", + "⏳ Скидки по периодам:", + ) + + lines = [header] + + for period_days, percent in discounts.items(): + period_display = format_period_description(period_days, language) + lines.append( + texts.t("PROMO_GROUP_PERIOD_DISCOUNT_ITEM", "{period} — {percent}%").format( + period=period_display, + percent=percent, + ) + ) + + return lines + + +def _format_period_discounts_value(discounts: Dict[int, int]) -> str: + if not discounts: + return "0" + + return ", ".join( + f"{period}:{percent}" + for period, percent in sorted(discounts.items()) + ) + + +def _parse_period_discounts_input(value: str) -> Dict[int, int]: + cleaned = (value or "").strip() + + if not cleaned or cleaned in {"0", "-"}: + return {} + + cleaned = cleaned.replace(";", ",").replace("\n", ",") + parts = [part.strip() for part in cleaned.split(",") if part.strip()] + + if not parts: + return {} + + discounts: Dict[int, int] = {} + + for part in parts: + if ":" not in part: + raise ValueError + + period_raw, percent_raw = part.split(":", 1) + + period = int(period_raw.strip()) + percent = int(percent_raw.strip()) + + if period <= 0: + raise ValueError + + discounts[period] = max(0, min(100, percent)) + + return discounts + + +async def _prompt_for_period_discounts( + message: types.Message, + state: FSMContext, + prompt_key: str, + default_text: str, + *, + current_value: Optional[str] = None, +): + data = await state.get_data() + texts = get_texts(data.get("language", "ru")) + prompt_text = texts.t(prompt_key, default_text) + + if current_value is not None: + try: + prompt_text = prompt_text.format(current=current_value) + except KeyError: + pass + + await message.answer(prompt_text) + + @admin_required @error_handler async def show_promo_groups_menu( @@ -64,17 +188,20 @@ async def show_promo_groups_menu( if group.is_default else "" ) - lines.extend( - [ - f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", - _format_discount_line(texts, group), - texts.t( - "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", - "Участников: {count}", - ).format(count=member_count), - "", - ] - ) + group_lines = [ + f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", + _format_discount_line(texts, group), + texts.t( + "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", + "Участников: {count}", + ).format(count=member_count), + ] + + period_lines = _format_period_discounts_lines(texts, group, db_user.language) + group_lines.extend(period_lines) + group_lines.append("") + + lines.extend(group_lines) keyboard_rows.append([ types.InlineKeyboardButton( text=f"{'⭐' if group.is_default else '🎯'} {group.name}", @@ -127,25 +254,30 @@ async def show_promo_group_details( member_count = await count_promo_group_members(db, group.id) default_note = ( - "\n" + texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.") + texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.") if group.is_default else "" ) - text = "\n".join( - [ - texts.t( - "ADMIN_PROMO_GROUP_DETAILS_TITLE", - "💳 Промогруппа: {name}", - ).format(name=group.name), - _format_discount_line(texts, group), - texts.t( - "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", - "Участников: {count}", - ).format(count=member_count), - default_note, - ] - ) + lines = [ + texts.t( + "ADMIN_PROMO_GROUP_DETAILS_TITLE", + "💳 Промогруппа: {name}", + ).format(name=group.name), + _format_discount_line(texts, group), + texts.t( + "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", + "Участников: {count}", + ).format(count=member_count), + ] + + period_lines = _format_period_discounts_lines(texts, group, db_user.language) + lines.extend(period_lines) + + if default_note: + lines.append(default_note) + + text = "\n".join(line for line in lines if line) keyboard_rows = [] if member_count > 0: @@ -299,13 +431,47 @@ async def process_create_group_devices( await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) return + await state.update_data(new_group_devices=devices_discount) + await state.set_state(AdminStates.creating_promo_group_period_discount) + + await _prompt_for_period_discounts( + message, + state, + "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT", + "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", + ) + + +@admin_required +@error_handler +async def process_create_group_period_discounts( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + period_discounts = _parse_period_discounts_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS", + "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", + ) + ) + return + try: group = await create_promo_group( db, data["new_group_name"], traffic_discount_percent=data["new_group_traffic"], server_discount_percent=data["new_group_servers"], - device_discount_percent=devices_discount, + device_discount_percent=data["new_group_devices"], + period_discounts=period_discounts, ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -440,13 +606,55 @@ async def process_edit_group_devices( await state.clear() return + await state.update_data(edit_group_devices=devices_discount) + await state.set_state(AdminStates.editing_promo_group_period_discount) + + current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) + await _prompt_for_period_discounts( + message, + state, + "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT", + "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", + current_value=_format_period_discounts_value(current_discounts), + ) + + +@admin_required +@error_handler +async def process_edit_group_period_discounts( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + period_discounts = _parse_period_discounts_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS", + "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", + ) + ) + return + + group = await get_promo_group_by_id(db, data["edit_group_id"]) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + await update_promo_group( db, group, name=data["edit_group_name"], traffic_discount_percent=data["edit_group_traffic"], server_discount_percent=data["edit_group_servers"], - device_discount_percent=devices_discount, + device_discount_percent=data["edit_group_devices"], + period_discounts=period_discounts, ) await state.clear() @@ -616,6 +824,10 @@ def register_handlers(dp: Dispatcher): process_create_group_devices, AdminStates.creating_promo_group_device_discount, ) + dp.message.register( + process_create_group_period_discounts, + AdminStates.creating_promo_group_period_discount, + ) dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name) dp.message.register( @@ -630,3 +842,7 @@ def register_handlers(dp: Dispatcher): process_edit_group_devices, AdminStates.editing_promo_group_device_discount, ) + dp.message.register( + process_edit_group_period_discounts, + AdminStates.editing_promo_group_period_discount, + ) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 9e924020..4e56123e 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -103,7 +103,15 @@ async def _prepare_subscription_summary( months_in_period = calculate_months_from_days(summary_data['period_days']) period_display = format_period_description(summary_data['period_days'], db_user.language) - base_price = PERIOD_PRICES[summary_data['period_days']] + base_price_original = PERIOD_PRICES[summary_data['period_days']] + period_discount_percent = db_user.get_promo_discount( + "period", + summary_data['period_days'], + ) + base_price, base_discount_total = apply_percentage_discount( + base_price_original, + period_discount_percent, + ) if settings.is_traffic_fixed(): traffic_limit = settings.get_fixed_traffic_limit() @@ -195,6 +203,9 @@ async def _prepare_subscription_summary( summary_data['server_prices_for_period'] = selected_server_prices summary_data['months_in_period'] = months_in_period summary_data['base_price'] = base_price + summary_data['base_price_original'] = base_price_original + summary_data['base_discount_percent'] = period_discount_percent + summary_data['base_discount_total'] = base_discount_total summary_data['final_traffic_gb'] = final_traffic_gb summary_data['traffic_price_per_month'] = traffic_price_per_month summary_data['traffic_discount_percent'] = traffic_component["discount_percent"] @@ -226,7 +237,15 @@ async def _prepare_subscription_summary( else: traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ" - details_lines = [f"- Базовый период: {texts.format_price(base_price)}"] + base_line = f"- Базовый период: {texts.format_price(base_price_original)}" + if base_discount_total > 0: + base_line += ( + f" → {texts.format_price(base_price)}" + f" (скидка {period_discount_percent}%:" + f" -{texts.format_price(base_discount_total)})" + ) + + details_lines = [base_line] if total_traffic_price > 0: traffic_line = ( @@ -317,26 +336,29 @@ def _build_promo_group_discount_text( period_lines: List[str] = [] - if ( - promo_group.is_default - and periods - and settings.is_base_promo_group_period_discount_enabled() - ): - discounts = settings.get_base_promo_group_period_discounts() + period_candidates: set[int] = set(periods or []) - for period_days in periods: - percent = discounts.get(period_days, 0) - - if percent <= 0: + raw_period_discounts = getattr(promo_group, "period_discounts", None) + if isinstance(raw_period_discounts, dict): + for key in raw_period_discounts.keys(): + try: + period_candidates.add(int(key)) + except (TypeError, ValueError): continue - period_display = format_period_description(period_days, db_user.language) - period_lines.append( - texts.PROMO_GROUP_PERIOD_DISCOUNT_ITEM.format( - period=period_display, - percent=percent, - ) + for period_days in sorted(period_candidates): + percent = promo_group.get_discount_percent("period", period_days) + + if percent <= 0: + continue + + period_display = format_period_description(period_days, db_user.language) + period_lines.append( + texts.PROMO_GROUP_PERIOD_DISCOUNT_ITEM.format( + period=period_display, + percent=percent, ) + ) if not service_lines and not period_lines: return "" @@ -666,8 +688,26 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: subscription_service = SubscriptionService() - base_cost = PERIOD_PRICES.get(30, 0) - + base_cost_original = PERIOD_PRICES.get(30, 0) + try: + owner = subscription.user + except AttributeError: + owner = None + + period_discount_percent = 0 + if owner: + try: + period_discount_percent = owner.get_promo_discount("period", 30) + except AttributeError: + period_discount_percent = 0 + + from app.utils.pricing_utils import apply_percentage_discount + + base_cost, _ = apply_percentage_discount( + base_cost_original, + period_discount_percent, + ) + try: servers_cost, _ = await subscription_service.get_countries_price_by_uuids( subscription.connected_squads, db @@ -683,7 +723,14 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: total_cost = base_cost + servers_cost + traffic_cost + devices_cost logger.info(f"📊 Месячная стоимость конфигурации подписки {subscription.id}:") - logger.info(f" 📅 Базовый тариф (30 дней): {base_cost/100}₽") + base_log = f" 📅 Базовый тариф (30 дней): {base_cost_original/100}₽" + if period_discount_percent > 0: + discount_value = base_cost_original * period_discount_percent // 100 + base_log += ( + f" → {base_cost/100}₽" + f" (скидка {period_discount_percent}%: -{discount_value/100}₽)" + ) + logger.info(base_log) if servers_cost > 0: logger.info(f" 🌍 Серверы: {servers_cost/100}₽") if traffic_cost > 0: @@ -1801,7 +1848,14 @@ async def handle_extend_subscription( months_in_period = calculate_months_from_days(days) from app.config import PERIOD_PRICES - base_price = PERIOD_PRICES.get(days, 0) + from app.utils.pricing_utils import apply_percentage_discount + + base_price_original = PERIOD_PRICES.get(days, 0) + period_discount_percent = db_user.get_promo_discount("period", days) + base_price, _ = apply_percentage_discount( + base_price_original, + period_discount_percent, + ) servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids( subscription.connected_squads, db @@ -2015,7 +2069,11 @@ async def confirm_extend_subscription( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import calculate_months_from_days, validate_pricing_calculation + from app.utils.pricing_utils import ( + calculate_months_from_days, + validate_pricing_calculation, + apply_percentage_discount, + ) from app.services.admin_notification_service import AdminNotificationService days = int(callback.data.split('_')[2]) @@ -2032,8 +2090,14 @@ async def confirm_extend_subscription( try: from app.config import PERIOD_PRICES + from app.utils.pricing_utils import apply_percentage_discount - base_price = PERIOD_PRICES.get(days, 0) + base_price_original = PERIOD_PRICES.get(days, 0) + period_discount_percent = db_user.get_promo_discount("period", days) + base_price, base_discount_total = apply_percentage_discount( + base_price_original, + period_discount_percent, + ) subscription_service = SubscriptionService() servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids( @@ -2091,7 +2155,13 @@ async def confirm_extend_subscription( return logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):") - logger.info(f" 📅 Период {days} дней: {base_price/100}₽") + base_log = f" 📅 Период {days} дней: {base_price_original/100}₽" + if base_discount_total > 0: + base_log += ( + f" → {base_price/100}₽" + f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" + ) + logger.info(base_log) if total_servers_price > 0: logger.info( f" 🌐 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period}" @@ -2543,7 +2613,15 @@ async def select_country( countries = await _get_available_countries() - base_price = PERIOD_PRICES[data['period_days']] + settings.get_traffic_price(data['traffic_gb']) + period_base_price = PERIOD_PRICES[data['period_days']] + from app.utils.pricing_utils import apply_percentage_discount + + discounted_base_price, _ = apply_percentage_discount( + period_base_price, + db_user.get_promo_discount("period", data['period_days']), + ) + + base_price = discounted_base_price + settings.get_traffic_price(data['traffic_gb']) try: subscription_service = SubscriptionService() @@ -2683,7 +2761,34 @@ async def confirm_purchase( 'months_in_period', calculate_months_from_days(data['period_days']) ) - base_price = data.get('base_price', PERIOD_PRICES[data['period_days']]) + base_price = data.get('base_price') + base_price_original = data.get('base_price_original') + base_discount_percent = data.get('base_discount_percent') + base_discount_total = data.get('base_discount_total') + + if base_price is None: + base_price_original = PERIOD_PRICES[data['period_days']] + base_discount_percent = db_user.get_promo_discount( + "period", + data['period_days'], + ) + base_price, base_discount_total = apply_percentage_discount( + base_price_original, + base_discount_percent, + ) + else: + if base_price_original is None: + base_price_original = PERIOD_PRICES[data['period_days']] + if base_discount_percent is None: + base_discount_percent = db_user.get_promo_discount( + "period", + data['period_days'], + ) + if base_discount_total is None: + _, base_discount_total = apply_percentage_discount( + base_price_original, + base_discount_percent, + ) server_prices = data.get('server_prices_for_period', []) if not server_prices: @@ -2812,7 +2917,13 @@ async def confirm_purchase( return logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):") - logger.info(f" Период: {base_price/100}₽") + base_log = f" Период: {base_price_original/100}₽" + if base_discount_total and base_discount_total > 0: + base_log += ( + f" → {base_price/100}₽" + f" (скидка {base_discount_percent}%: -{base_discount_total/100}₽)" + ) + logger.info(base_log) if total_traffic_price > 0: message = ( f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}" diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 1b380dba..e21e259c 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -299,7 +299,15 @@ class SubscriptionService: if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT: raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}") - base_price = PERIOD_PRICES.get(period_days, 0) + base_price_original = PERIOD_PRICES.get(period_days, 0) + period_discount_percent = _resolve_discount_percent( + user, + promo_group, + "period", + period_days=period_days, + ) + base_discount_total = base_price_original * period_discount_percent // 100 + base_price = base_price_original - base_discount_total promo_group = promo_group or (user.promo_group if user else None) @@ -353,7 +361,13 @@ class SubscriptionService: total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price logger.info(f"Расчет стоимости новой подписки:") - logger.info(f" Период {period_days} дней: {base_price/100}₽") + base_log = f" Период {period_days} дней: {base_price_original/100}₽" + if base_discount_total > 0: + base_log += ( + f" → {base_price/100}₽" + f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" + ) + logger.info(base_log) if discounted_traffic_price > 0: message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽" if traffic_discount > 0: @@ -391,7 +405,7 @@ class SubscriptionService: try: from app.config import PERIOD_PRICES - base_price = PERIOD_PRICES.get(period_days, 0) + base_price_original = PERIOD_PRICES.get(period_days, 0) if user is None: user = getattr(subscription, "user", None) @@ -430,6 +444,15 @@ class SubscriptionService: traffic_discount = traffic_price * traffic_discount_percent // 100 discounted_traffic_price = traffic_price - traffic_discount + period_discount_percent = _resolve_discount_percent( + user, + promo_group, + "period", + period_days=period_days, + ) + base_discount_total = base_price_original * period_discount_percent // 100 + base_price = base_price_original - base_discount_total + total_price = ( base_price + discounted_servers_price @@ -438,7 +461,13 @@ class SubscriptionService: ) logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):") - logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽") + base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽" + if base_discount_total > 0: + base_log += ( + f" → {base_price/100}₽" + f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" + ) + logger.info(base_log) if servers_price > 0: message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}₽" if servers_discount > 0: @@ -577,7 +606,15 @@ class SubscriptionService: months_in_period = calculate_months_from_days(period_days) - base_price = PERIOD_PRICES.get(period_days, 0) + base_price_original = PERIOD_PRICES.get(period_days, 0) + period_discount_percent = _resolve_discount_percent( + user, + promo_group, + "period", + period_days=period_days, + ) + base_discount_total = base_price_original * period_discount_percent // 100 + base_price = base_price_original - base_discount_total promo_group = promo_group or (user.promo_group if user else None) @@ -637,7 +674,13 @@ class SubscriptionService: total_price = base_price + total_traffic_price + total_servers_price + total_devices_price logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):") - logger.info(f" Период {period_days} дней: {base_price/100}₽") + base_log = f" Период {period_days} дней: {base_price_original/100}₽" + if base_discount_total > 0: + base_log += ( + f" → {base_price/100}₽" + f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" + ) + logger.info(base_log) if total_traffic_price > 0: message = ( f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽" @@ -681,7 +724,7 @@ class SubscriptionService: months_in_period = calculate_months_from_days(period_days) - base_price = PERIOD_PRICES.get(period_days, 0) + base_price_original = PERIOD_PRICES.get(period_days, 0) if user is None: user = getattr(subscription, "user", None) @@ -723,10 +766,25 @@ class SubscriptionService: discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month total_traffic_price = discounted_traffic_per_month * months_in_period + period_discount_percent = _resolve_discount_percent( + user, + promo_group, + "period", + period_days=period_days, + ) + base_discount_total = base_price_original * period_discount_percent // 100 + base_price = base_price_original - base_discount_total + total_price = base_price + total_servers_price + total_devices_price + total_traffic_price logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):") - logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽") + base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽" + if base_discount_total > 0: + base_log += ( + f" → {base_price/100}₽" + f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" + ) + logger.info(base_log) if total_servers_price > 0: message = ( f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽" diff --git a/app/states.py b/app/states.py index 2207c448..0073a4d4 100644 --- a/app/states.py +++ b/app/states.py @@ -68,11 +68,13 @@ class AdminStates(StatesGroup): creating_promo_group_traffic_discount = State() creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() + creating_promo_group_period_discount = State() editing_promo_group_name = State() editing_promo_group_traffic_discount = State() editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() + editing_promo_group_period_discount = State() editing_squad_price = State() editing_traffic_price = State() diff --git a/locales/en.json b/locales/en.json index a38f1744..419dbe95 100644 --- a/locales/en.json +++ b/locales/en.json @@ -137,6 +137,7 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", @@ -224,13 +225,16 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", + "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", + "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", diff --git a/locales/ru.json b/locales/ru.json index eeb76649..e69b9d92 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -15,6 +15,7 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", @@ -102,13 +103,16 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", + "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", diff --git a/migrations/alembic/versions/4b6b0f58c8f9_add_period_discounts_to_promo_groups.py b/migrations/alembic/versions/4b6b0f58c8f9_add_period_discounts_to_promo_groups.py new file mode 100644 index 00000000..4f3518cd --- /dev/null +++ b/migrations/alembic/versions/4b6b0f58c8f9_add_period_discounts_to_promo_groups.py @@ -0,0 +1,29 @@ +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "4b6b0f58c8f9" +down_revision: Union[str, None] = "1f5f3a3f5a4d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + dialect = bind.dialect.name if bind else "" + + op.add_column( + "promo_groups", + sa.Column("period_discounts", sa.JSON(), nullable=True), + ) + + if dialect == "postgresql": + op.execute("UPDATE promo_groups SET period_discounts = '{}'::jsonb WHERE period_discounts IS NULL") + else: + op.execute("UPDATE promo_groups SET period_discounts = '{}' WHERE period_discounts IS NULL") + + +def downgrade() -> None: + op.drop_column("promo_groups", "period_discounts") From de0b361062e51ff1fb4bf95d6cc427a507238f6a Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 05:42:49 +0300 Subject: [PATCH 020/146] Add auto promo group assignment on top-up --- app/database/crud/promo_group.py | 47 +++++++- app/database/crud/transaction.py | 15 +++ app/database/crud/user.py | 1 + app/database/models.py | 4 +- app/database/universal_migration.py | 93 +++++++++++++++ app/handlers/admin/promo_groups.py | 168 +++++++++++++++++++++++++++- app/services/payment_service.py | 20 +++- app/services/promo_group_service.py | 77 +++++++++++++ app/services/tribute_service.py | 15 ++- app/states.py | 2 + locales/en.json | 5 + locales/ru.json | 5 + 12 files changed, 435 insertions(+), 17 deletions(-) create mode 100644 app/services/promo_group_service.py diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index d63c3107..7e5fab72 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -1,7 +1,7 @@ import logging -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple -from sqlalchemy import func, select, update +from sqlalchemy import desc, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -27,6 +27,23 @@ def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> D logger = logging.getLogger(__name__) +_UNSET = object() + + +def _normalize_auto_assign_amount(amount_kopeks: Optional[int]) -> Optional[int]: + if amount_kopeks is None: + return None + + try: + normalized = int(amount_kopeks) + except (TypeError, ValueError): + return None + + if normalized <= 0: + return None + + return normalized + async def get_promo_groups_with_counts( db: AsyncSession, @@ -59,8 +76,10 @@ async def create_promo_group( traffic_discount_percent: int, device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, + auto_assign_amount_kopeks: Optional[int] = None, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) + normalized_auto_amount = _normalize_auto_assign_amount(auto_assign_amount_kopeks) promo_group = PromoGroup( name=name.strip(), @@ -68,6 +87,7 @@ async def create_promo_group( traffic_discount_percent=max(0, min(100, traffic_discount_percent)), device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, + auto_assign_amount_kopeks=normalized_auto_amount, is_default=False, ) @@ -96,6 +116,7 @@ async def update_promo_group( traffic_discount_percent: Optional[int] = None, device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, + auto_assign_amount_kopeks: Any = _UNSET, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -108,6 +129,8 @@ async def update_promo_group( if period_discounts is not None: normalized_period_discounts = _normalize_period_discounts(period_discounts) group.period_discounts = normalized_period_discounts or None + if auto_assign_amount_kopeks is not _UNSET: + group.auto_assign_amount_kopeks = _normalize_auto_assign_amount(auto_assign_amount_kopeks) await db.commit() await db.refresh(group) @@ -170,3 +193,23 @@ async def count_promo_group_members(db: AsyncSession, group_id: int) -> int: select(func.count(User.id)).where(User.promo_group_id == group_id) ) return result.scalar_one() + + +async def get_auto_assign_promo_group( + db: AsyncSession, + total_amount_kopeks: int, +) -> Optional[PromoGroup]: + if total_amount_kopeks <= 0: + return None + + result = await db.execute( + select(PromoGroup) + .where( + PromoGroup.auto_assign_amount_kopeks.is_not(None), + PromoGroup.auto_assign_amount_kopeks > 0, + PromoGroup.auto_assign_amount_kopeks <= total_amount_kopeks, + ) + .order_by(desc(PromoGroup.auto_assign_amount_kopeks), PromoGroup.id) + ) + + return result.scalars().first() diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index b258f1b2..5102100a 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -98,6 +98,21 @@ async def get_user_transactions_count( return result.scalar() +async def get_user_total_completed_deposits(db: AsyncSession, user_id: int) -> int: + result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.user_id == user_id, + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed.is_(True), + ) + ) + ) + + return result.scalar_one() + + async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction: transaction.is_completed = True diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 582c8695..4e228735 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -116,6 +116,7 @@ async def create_user( has_had_paid_subscription=False, has_made_first_topup=False, promo_group_id=promo_group_id, + promo_group_auto_assigned=False, ) db.add(user) diff --git a/app/database/models.py b/app/database/models.py index 9cdeaa86..df882864 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -271,6 +271,7 @@ class PromoGroup(Base): traffic_discount_percent = Column(Integer, nullable=False, default=0) device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) + auto_assign_amount_kopeks = Column(Integer, nullable=True) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -335,7 +336,7 @@ class PromoGroup(Base): class User(Base): __tablename__ = "users" - + id = Column(Integer, primary_key=True, index=True) telegram_id = Column(BigInteger, unique=True, index=True, nullable=False) username = Column(String(255), nullable=True) @@ -365,6 +366,7 @@ class User(Base): has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True) promo_group = relationship("PromoGroup", back_populates="users") + promo_group_auto_assigned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) @property def balance_rubles(self) -> float: diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index ce270f13..17179520 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -902,6 +902,81 @@ async def ensure_promo_groups_setup(): logger.error(f"Ошибка настройки промо групп: {e}") return False + +async def add_promo_group_auto_assign_column(): + logger.info("=== ДОБАВЛЕНИЕ ПОЛЯ АВТОВЫДАЧИ ДЛЯ ПРОМОГРУПП ===") + + try: + if await check_column_exists("promo_groups", "auto_assign_amount_kopeks"): + logger.info("Колонка auto_assign_amount_kopeks уже существует в promo_groups") + return True + + db_type = await get_database_type() + + if db_type == "sqlite": + column_definition = "INTEGER" + elif db_type == "postgresql": + column_definition = "INTEGER" + elif db_type == "mysql": + column_definition = "INT" + else: + logger.error(f"Неподдерживаемый тип БД для auto_assign_amount_kopeks: {db_type}") + return False + + async with engine.begin() as conn: + await conn.execute( + text( + f"ALTER TABLE promo_groups ADD COLUMN auto_assign_amount_kopeks {column_definition}" + ) + ) + + logger.info("Добавлена колонка promo_groups.auto_assign_amount_kopeks") + return True + + except Exception as e: + logger.error(f"Ошибка добавления колонки auto_assign_amount_kopeks: {e}") + return False + + +async def add_user_promo_group_auto_flag_column(): + logger.info("=== ДОБАВЛЕНИЕ ФЛАГА АВТО-ПРОМОГРУППЫ ДЛЯ ПОЛЬЗОВАТЕЛЕЙ ===") + + try: + if await check_column_exists("users", "promo_group_auto_assigned"): + logger.info("Колонка promo_group_auto_assigned уже существует в users") + return True + + db_type = await get_database_type() + + if db_type == "sqlite": + column_definition = "BOOLEAN NOT NULL DEFAULT 0" + reset_sql = "UPDATE users SET promo_group_auto_assigned = 0 WHERE promo_group_auto_assigned IS NULL" + elif db_type == "postgresql": + column_definition = "BOOLEAN NOT NULL DEFAULT FALSE" + reset_sql = "UPDATE users SET promo_group_auto_assigned = FALSE WHERE promo_group_auto_assigned IS NULL" + elif db_type == "mysql": + column_definition = "TINYINT(1) NOT NULL DEFAULT 0" + reset_sql = "UPDATE users SET promo_group_auto_assigned = 0 WHERE promo_group_auto_assigned IS NULL" + else: + logger.error(f"Неподдерживаемый тип БД для promo_group_auto_assigned: {db_type}") + return False + + async with engine.begin() as conn: + await conn.execute( + text( + f"ALTER TABLE users ADD COLUMN promo_group_auto_assigned {column_definition}" + ) + ) + await conn.execute(text(reset_sql)) + + logger.info("Добавлена колонка users.promo_group_auto_assigned") + return True + + except Exception as e: + logger.error(f"Ошибка добавления колонки promo_group_auto_assigned: {e}") + return False + + async def add_welcome_text_is_enabled_column(): column_exists = await check_column_exists('welcome_texts', 'is_enabled') if column_exists: @@ -1511,6 +1586,18 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с настройкой промо групп") + promo_auto_column_added = await add_promo_group_auto_assign_column() + if promo_auto_column_added: + logger.info("✅ Добавлено поле авто-выдачи в промо группах") + else: + logger.warning("⚠️ Не удалось добавить поле авто-выдачи в промо группах") + + user_auto_flag_added = await add_user_promo_group_auto_flag_column() + if user_auto_flag_added: + logger.info("✅ Добавлен флаг auto_assigned у пользователей") + else: + logger.warning("⚠️ Не удалось добавить флаг auto_assigned у пользователей") + logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: @@ -1585,6 +1672,8 @@ async def check_migration_status(): "promo_groups_table": False, "users_promo_group_column": False, "promo_groups_period_discounts_column": False, + "promo_groups_auto_assign_column": False, + "users_promo_group_auto_flag_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1598,6 +1687,8 @@ async def check_migration_status(): status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') + status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_amount_kopeks') + status["users_promo_group_auto_flag_column"] = await check_column_exists('users', 'promo_group_auto_assigned') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -1631,6 +1722,8 @@ async def check_migration_status(): "promo_groups_table": "Таблица промо-групп", "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", + "promo_groups_auto_assign_column": "Колонка auto_assign_amount_kopeks у промо-групп", + "users_promo_group_auto_flag_column": "Флаг auto_assigned у пользователей", } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 0546550e..567eead2 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -1,5 +1,5 @@ import logging -import logging +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Dict, Optional from aiogram import Dispatcher, types, F @@ -109,6 +109,23 @@ def _format_period_discounts_value(discounts: Dict[int, int]) -> str: ) +def _format_auto_assign_line(texts, group: PromoGroup) -> Optional[str]: + amount = getattr(group, "auto_assign_amount_kopeks", None) + if not amount: + return None + + return texts.t( + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE", + "🎯 Автовыдача с суммы: {amount}", + ).format(amount=settings.format_price(amount)) + + +def _format_auto_assign_value(amount_kopeks: Optional[int]) -> str: + if not amount_kopeks: + return settings.format_price(0) + return settings.format_price(amount_kopeks) + + def _parse_period_discounts_input(value: str) -> Dict[int, int]: cleaned = (value or "").strip() @@ -140,6 +157,29 @@ def _parse_period_discounts_input(value: str) -> Dict[int, int]: return discounts +def _parse_auto_assign_amount_input(value: str) -> Optional[int]: + cleaned = (value or "").strip() + + if not cleaned: + raise ValueError + + normalized = cleaned.replace(" ", "").replace(",", ".") + + if normalized in {"0", "-", "нет", "off", "disable"}: + return None + + try: + decimal_value = Decimal(normalized) + except (InvalidOperation, ValueError): + raise ValueError + + if decimal_value <= 0: + return None + + kopeks = (decimal_value * Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP) + return int(kopeks) + + async def _prompt_for_period_discounts( message: types.Message, state: FSMContext, @@ -161,6 +201,27 @@ async def _prompt_for_period_discounts( await message.answer(prompt_text) +async def _prompt_for_auto_assign_amount( + message: types.Message, + state: FSMContext, + prompt_key: str, + default_text: str, + *, + current_value: Optional[str] = None, +): + data = await state.get_data() + texts = get_texts(data.get("language", "ru")) + prompt_text = texts.t(prompt_key, default_text) + + if current_value is not None: + try: + prompt_text = prompt_text.format(current=current_value) + except KeyError: + pass + + await message.answer(prompt_text) + + @admin_required @error_handler async def show_promo_groups_menu( @@ -191,11 +252,18 @@ async def show_promo_groups_menu( group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), + ] + + auto_line = _format_auto_assign_line(texts, group) + if auto_line: + group_lines.append(auto_line) + + group_lines.extend([ texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", "Участников: {count}", ).format(count=member_count), - ] + ]) period_lines = _format_period_discounts_lines(texts, group, db_user.language) group_lines.extend(period_lines) @@ -265,11 +333,18 @@ async def show_promo_group_details( "💳 Промогруппа: {name}", ).format(name=group.name), _format_discount_line(texts, group), + ] + + auto_line = _format_auto_assign_line(texts, group) + if auto_line: + lines.append(auto_line) + + lines.append( texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", "Участников: {count}", ).format(count=member_count), - ] + ) period_lines = _format_period_discounts_lines(texts, group, db_user.language) lines.extend(period_lines) @@ -464,6 +539,39 @@ async def process_create_group_period_discounts( ) return + await state.update_data(new_group_period_discounts=period_discounts) + await state.set_state(AdminStates.creating_promo_group_auto_amount) + + await _prompt_for_auto_assign_amount( + message, + state, + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT", + "Введите сумму пополнений (в рублях) для автоматической выдачи. Отправьте 0, если не нужно.", + ) + + +@admin_required +@error_handler +async def process_create_group_auto_amount( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + auto_amount = _parse_auto_assign_amount_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN", + "Введите корректную сумму или 0 для отключения.", + ) + ) + return + try: group = await create_promo_group( db, @@ -471,7 +579,8 @@ async def process_create_group_period_discounts( traffic_discount_percent=data["new_group_traffic"], server_discount_percent=data["new_group_servers"], device_discount_percent=data["new_group_devices"], - period_discounts=period_discounts, + period_discounts=data.get("new_group_period_discounts"), + auto_assign_amount_kopeks=auto_amount, ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -647,6 +756,46 @@ async def process_edit_group_period_discounts( await state.clear() return + await state.update_data(edit_group_period_discounts=period_discounts) + await state.set_state(AdminStates.editing_promo_group_auto_amount) + + await _prompt_for_auto_assign_amount( + message, + state, + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT", + "Введите новую сумму (текущая: {current}). Отправьте 0, если без автовыдачи.", + current_value=_format_auto_assign_value(getattr(group, "auto_assign_amount_kopeks", None)), + ) + + +@admin_required +@error_handler +async def process_edit_group_auto_amount( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + auto_amount = _parse_auto_assign_amount_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN", + "Введите корректную сумму или 0 для отключения.", + ) + ) + return + + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + await update_promo_group( db, group, @@ -654,7 +803,8 @@ async def process_edit_group_period_discounts( traffic_discount_percent=data["edit_group_traffic"], server_discount_percent=data["edit_group_servers"], device_discount_percent=data["edit_group_devices"], - period_discounts=period_discounts, + period_discounts=data.get("edit_group_period_discounts"), + auto_assign_amount_kopeks=auto_amount, ) await state.clear() @@ -828,6 +978,10 @@ def register_handlers(dp: Dispatcher): process_create_group_period_discounts, AdminStates.creating_promo_group_period_discount, ) + dp.message.register( + process_create_group_auto_amount, + AdminStates.creating_promo_group_auto_amount, + ) dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name) dp.message.register( @@ -846,3 +1000,7 @@ def register_handlers(dp: Dispatcher): process_edit_group_period_discounts, AdminStates.editing_promo_group_period_discount, ) + dp.message.register( + process_edit_group_auto_amount, + AdminStates.editing_promo_group_auto_amount, + ) diff --git a/app/services/payment_service.py b/app/services/payment_service.py index a761ef5c..17c39bd7 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -30,6 +30,7 @@ from app.services.subscription_checkout_service import ( ) from app.services.mulenpay_service import MulenPayService from app.services.pal24_service import Pal24Service, Pal24APIError +from app.services.promo_group_service import maybe_assign_auto_promo_group from app.database.crud.mulenpay import ( create_mulenpay_payment, get_mulenpay_payment_by_local_id, @@ -179,7 +180,9 @@ class PaymentService: logger.error(f"Ошибка обработки реферального пополнения: {e}") else: logger.info(f"❌ Описание '{description_for_referral}' не подходит для реферальной логики") - + + await maybe_assign_auto_promo_group(db, user, self.bot) + if self.bot: try: from app.services.admin_notification_service import AdminNotificationService @@ -461,7 +464,9 @@ class PaymentService: await process_referral_topup(db, user.id, updated_payment.amount_kopeks, self.bot) except Exception as e: logger.error(f"Ошибка обработки реферального пополнения YooKassa: {e}") - + + await maybe_assign_auto_promo_group(db, user, self.bot) + if self.bot: try: from app.services.admin_notification_service import AdminNotificationService @@ -528,7 +533,8 @@ class PaymentService: user = await get_user_by_id(db, payment.user_id) if user: await add_user_balance(db, user, payment.amount_kopeks, f"Пополнение YooKassa: {payment.amount_kopeks//100}₽") - + await maybe_assign_auto_promo_group(db, user, self.bot) + logger.info(f"Успешно обработан платеж YooKassa {payment.yookassa_payment_id}: " f"пользователь {payment.user_id} получил {payment.amount_kopeks/100}₽") @@ -993,6 +999,8 @@ class PaymentService: referral_error, ) + await maybe_assign_auto_promo_group(db, user, self.bot) + await update_mulenpay_payment_status( db, payment=payment, @@ -1182,6 +1190,8 @@ class PaymentService: except Exception as referral_error: logger.error("Ошибка обработки реферального пополнения Pal24: %s", referral_error) + await maybe_assign_auto_promo_group(db, user, self.bot) + if self.bot: try: from app.services.admin_notification_service import AdminNotificationService @@ -1438,7 +1448,9 @@ class PaymentService: await process_referral_topup(db, user.id, amount_kopeks, self.bot) except Exception as e: logger.error(f"Ошибка обработки реферального пополнения CryptoBot: {e}") - + + await maybe_assign_auto_promo_group(db, user, self.bot) + if self.bot: try: from app.services.admin_notification_service import AdminNotificationService diff --git a/app/services/promo_group_service.py b/app/services/promo_group_service.py new file mode 100644 index 00000000..02ad4dbc --- /dev/null +++ b/app/services/promo_group_service.py @@ -0,0 +1,77 @@ +import logging +from datetime import datetime +from typing import Optional + +from aiogram import Bot +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.promo_group import get_auto_assign_promo_group +from app.database.crud.transaction import get_user_total_completed_deposits +from app.database.models import PromoGroup, User +from app.localization.texts import get_texts + +logger = logging.getLogger(__name__) + + +def _format_total_amount(total_amount_kopeks: int) -> str: + return settings.format_price(total_amount_kopeks) + + +async def maybe_assign_auto_promo_group( + db: AsyncSession, + user: User, + bot: Optional[Bot] = None, +) -> Optional[PromoGroup]: + """Назначает промогруппу автоматически при достижении нужной суммы пополнений.""" + try: + if getattr(user, "promo_group_auto_assigned", False): + return None + + total_amount_kopeks = await get_user_total_completed_deposits(db, user.id) + target_group = await get_auto_assign_promo_group(db, total_amount_kopeks) + + if not target_group or target_group.id == user.promo_group_id: + return None + + user.promo_group_id = target_group.id + user.promo_group = target_group + user.promo_group_auto_assigned = True + user.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(user) + + logger.info( + "Автоматически назначена промогруппа '%s' пользователю %s (сумма пополнений: %s)", + target_group.name, + user.telegram_id, + _format_total_amount(total_amount_kopeks), + ) + + if bot: + try: + texts = get_texts(user.language) + message = texts.t( + "PROMO_GROUP_AUTO_ASSIGN_NOTIFICATION", + "🎉 Вы автоматически переведены в промогруппу «{name}» за пополнения на {amount}.", + ).format(name=target_group.name, amount=_format_total_amount(total_amount_kopeks)) + await bot.send_message(user.telegram_id, message, parse_mode="HTML") + except Exception as notify_error: + logger.error( + "Ошибка отправки уведомления об автоназначении промогруппы пользователю %s: %s", + user.telegram_id, + notify_error, + ) + + return target_group + + except Exception as error: + logger.error( + "Ошибка автоматического назначения промогруппы пользователю %s: %s", + getattr(user, "telegram_id", "unknown"), + error, + exc_info=True, + ) + await db.rollback() + return None diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index 6d5de2eb..8dd427f4 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -14,6 +14,7 @@ from app.database.crud.transaction import ( from app.database.crud.user import get_user_by_telegram_id, add_user_balance from app.external.tribute import TributeService as TributeAPI from app.services.payment_service import PaymentService +from app.services.promo_group_service import maybe_assign_auto_promo_group logger = logging.getLogger(__name__) @@ -139,8 +140,10 @@ class TributeService: if not user.has_made_first_topup: user.has_made_first_topup = True logger.info(f"Отмечен первый топап для пользователя {user_telegram_id}") - - + + await maybe_assign_auto_promo_group(session, user, self.bot) + + try: from app.services.admin_notification_service import AdminNotificationService notification_service = AdminNotificationService(self.bot) @@ -333,11 +336,13 @@ class TributeService: old_balance = user.balance_kopeks user.balance_kopeks += amount_kopeks user.updated_at = datetime.utcnow() - + await session.commit() - + + await maybe_assign_auto_promo_group(session, user, self.bot) + logger.info(f"💰 ПРИНУДИТЕЛЬНО обновлен баланс: {old_balance} -> {user.balance_kopeks} коп") - + await self._send_success_notification(user_id, amount_kopeks) logger.info(f"✅ Принудительно обработан платеж {payment_id}") diff --git a/app/states.py b/app/states.py index 0073a4d4..940986d2 100644 --- a/app/states.py +++ b/app/states.py @@ -69,12 +69,14 @@ class AdminStates(StatesGroup): creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() creating_promo_group_period_discount = State() + creating_promo_group_auto_amount = State() editing_promo_group_name = State() editing_promo_group_traffic_discount = State() editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() editing_promo_group_period_discount = State() + editing_promo_group_auto_amount = State() editing_squad_price = State() editing_traffic_price = State() diff --git a/locales/en.json b/locales/en.json index 419dbe95..51508f0d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -141,6 +141,7 @@ "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "🎯 Auto assignment from: {amount}", "CREATE_TICKET_BUTTON": "🎫 Create ticket", "MY_TICKETS_BUTTON": "📋 My tickets", "CONTACT_SUPPORT_BUTTON": "💬 Contact support", @@ -226,8 +227,10 @@ "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter the top-up amount (in rubles) for automatic assignment. Send 0 to disable.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a valid amount or 0 to disable.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", @@ -235,6 +238,7 @@ "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter a new amount (current: {current}). Send 0 to disable.", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", @@ -263,6 +267,7 @@ "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%", "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:", "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", + "PROMO_GROUP_AUTO_ASSIGN_NOTIFICATION": "🎉 You have been automatically moved to the promo group “{name}” for topping up {amount}.", "CHANGE_DEVICES_CONFIRM": "\n📱 Confirm change\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", "CHANGE_DEVICES_INFO": "\n📱 Adjust device limit\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 Important:\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n", "CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n", diff --git a/locales/ru.json b/locales/ru.json index e69b9d92..ac1c6d0d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -19,6 +19,7 @@ "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "🎯 Автовыдача с суммы: {amount}", "CREATE_TICKET_BUTTON": "🎫 Создать тикет", "MY_TICKETS_BUTTON": "📋 Мои тикеты", "CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой", @@ -104,8 +105,10 @@ "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму пополнений (в рублях) для автоматической выдачи. Отправьте 0, если без автовыдачи.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите корректную сумму или 0 для отключения.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", @@ -113,6 +116,7 @@ "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите новую сумму (текущая: {current}). Отправьте 0, если без автовыдачи.", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", @@ -146,6 +150,7 @@ "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%", "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:", "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", + "PROMO_GROUP_AUTO_ASSIGN_NOTIFICATION": "🎉 Вы автоматически переведены в промогруппу «{name}» за пополнения на {amount}.", "CANCEL": "❌ Отмена", "CHANGE_DEVICES_BUTTON": "📱 Изменить устройства", "CHANGE_DEVICES_CONFIRM": "\n 📱 Подтверждение изменения\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ", From f896d5960977dcf12ebc22670e9298ba96514b72 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 05:49:30 +0300 Subject: [PATCH 021/146] Revert "Add promo group auto assignment after top-ups" --- app/database/crud/promo_group.py | 47 +------- app/database/crud/transaction.py | 15 --- app/database/crud/user.py | 1 - app/database/models.py | 4 +- app/database/universal_migration.py | 93 --------------- app/handlers/admin/promo_groups.py | 168 +--------------------------- app/services/payment_service.py | 20 +--- app/services/promo_group_service.py | 77 ------------- app/services/tribute_service.py | 15 +-- app/states.py | 2 - locales/en.json | 5 - locales/ru.json | 5 - 12 files changed, 17 insertions(+), 435 deletions(-) delete mode 100644 app/services/promo_group_service.py diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 7e5fab72..d63c3107 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -1,7 +1,7 @@ import logging -from typing import Any, Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple -from sqlalchemy import desc, func, select, update +from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -27,23 +27,6 @@ def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> D logger = logging.getLogger(__name__) -_UNSET = object() - - -def _normalize_auto_assign_amount(amount_kopeks: Optional[int]) -> Optional[int]: - if amount_kopeks is None: - return None - - try: - normalized = int(amount_kopeks) - except (TypeError, ValueError): - return None - - if normalized <= 0: - return None - - return normalized - async def get_promo_groups_with_counts( db: AsyncSession, @@ -76,10 +59,8 @@ async def create_promo_group( traffic_discount_percent: int, device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, - auto_assign_amount_kopeks: Optional[int] = None, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) - normalized_auto_amount = _normalize_auto_assign_amount(auto_assign_amount_kopeks) promo_group = PromoGroup( name=name.strip(), @@ -87,7 +68,6 @@ async def create_promo_group( traffic_discount_percent=max(0, min(100, traffic_discount_percent)), device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, - auto_assign_amount_kopeks=normalized_auto_amount, is_default=False, ) @@ -116,7 +96,6 @@ async def update_promo_group( traffic_discount_percent: Optional[int] = None, device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, - auto_assign_amount_kopeks: Any = _UNSET, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -129,8 +108,6 @@ async def update_promo_group( if period_discounts is not None: normalized_period_discounts = _normalize_period_discounts(period_discounts) group.period_discounts = normalized_period_discounts or None - if auto_assign_amount_kopeks is not _UNSET: - group.auto_assign_amount_kopeks = _normalize_auto_assign_amount(auto_assign_amount_kopeks) await db.commit() await db.refresh(group) @@ -193,23 +170,3 @@ async def count_promo_group_members(db: AsyncSession, group_id: int) -> int: select(func.count(User.id)).where(User.promo_group_id == group_id) ) return result.scalar_one() - - -async def get_auto_assign_promo_group( - db: AsyncSession, - total_amount_kopeks: int, -) -> Optional[PromoGroup]: - if total_amount_kopeks <= 0: - return None - - result = await db.execute( - select(PromoGroup) - .where( - PromoGroup.auto_assign_amount_kopeks.is_not(None), - PromoGroup.auto_assign_amount_kopeks > 0, - PromoGroup.auto_assign_amount_kopeks <= total_amount_kopeks, - ) - .order_by(desc(PromoGroup.auto_assign_amount_kopeks), PromoGroup.id) - ) - - return result.scalars().first() diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index 5102100a..b258f1b2 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -98,21 +98,6 @@ async def get_user_transactions_count( return result.scalar() -async def get_user_total_completed_deposits(db: AsyncSession, user_id: int) -> int: - result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( - and_( - Transaction.user_id == user_id, - Transaction.type == TransactionType.DEPOSIT.value, - Transaction.is_completed.is_(True), - ) - ) - ) - - return result.scalar_one() - - async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction: transaction.is_completed = True diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 4e228735..582c8695 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -116,7 +116,6 @@ async def create_user( has_had_paid_subscription=False, has_made_first_topup=False, promo_group_id=promo_group_id, - promo_group_auto_assigned=False, ) db.add(user) diff --git a/app/database/models.py b/app/database/models.py index df882864..9cdeaa86 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -271,7 +271,6 @@ class PromoGroup(Base): traffic_discount_percent = Column(Integer, nullable=False, default=0) device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) - auto_assign_amount_kopeks = Column(Integer, nullable=True) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -336,7 +335,7 @@ class PromoGroup(Base): class User(Base): __tablename__ = "users" - + id = Column(Integer, primary_key=True, index=True) telegram_id = Column(BigInteger, unique=True, index=True, nullable=False) username = Column(String(255), nullable=True) @@ -366,7 +365,6 @@ class User(Base): has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True) promo_group = relationship("PromoGroup", back_populates="users") - promo_group_auto_assigned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) @property def balance_rubles(self) -> float: diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 17179520..ce270f13 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -902,81 +902,6 @@ async def ensure_promo_groups_setup(): logger.error(f"Ошибка настройки промо групп: {e}") return False - -async def add_promo_group_auto_assign_column(): - logger.info("=== ДОБАВЛЕНИЕ ПОЛЯ АВТОВЫДАЧИ ДЛЯ ПРОМОГРУПП ===") - - try: - if await check_column_exists("promo_groups", "auto_assign_amount_kopeks"): - logger.info("Колонка auto_assign_amount_kopeks уже существует в promo_groups") - return True - - db_type = await get_database_type() - - if db_type == "sqlite": - column_definition = "INTEGER" - elif db_type == "postgresql": - column_definition = "INTEGER" - elif db_type == "mysql": - column_definition = "INT" - else: - logger.error(f"Неподдерживаемый тип БД для auto_assign_amount_kopeks: {db_type}") - return False - - async with engine.begin() as conn: - await conn.execute( - text( - f"ALTER TABLE promo_groups ADD COLUMN auto_assign_amount_kopeks {column_definition}" - ) - ) - - logger.info("Добавлена колонка promo_groups.auto_assign_amount_kopeks") - return True - - except Exception as e: - logger.error(f"Ошибка добавления колонки auto_assign_amount_kopeks: {e}") - return False - - -async def add_user_promo_group_auto_flag_column(): - logger.info("=== ДОБАВЛЕНИЕ ФЛАГА АВТО-ПРОМОГРУППЫ ДЛЯ ПОЛЬЗОВАТЕЛЕЙ ===") - - try: - if await check_column_exists("users", "promo_group_auto_assigned"): - logger.info("Колонка promo_group_auto_assigned уже существует в users") - return True - - db_type = await get_database_type() - - if db_type == "sqlite": - column_definition = "BOOLEAN NOT NULL DEFAULT 0" - reset_sql = "UPDATE users SET promo_group_auto_assigned = 0 WHERE promo_group_auto_assigned IS NULL" - elif db_type == "postgresql": - column_definition = "BOOLEAN NOT NULL DEFAULT FALSE" - reset_sql = "UPDATE users SET promo_group_auto_assigned = FALSE WHERE promo_group_auto_assigned IS NULL" - elif db_type == "mysql": - column_definition = "TINYINT(1) NOT NULL DEFAULT 0" - reset_sql = "UPDATE users SET promo_group_auto_assigned = 0 WHERE promo_group_auto_assigned IS NULL" - else: - logger.error(f"Неподдерживаемый тип БД для promo_group_auto_assigned: {db_type}") - return False - - async with engine.begin() as conn: - await conn.execute( - text( - f"ALTER TABLE users ADD COLUMN promo_group_auto_assigned {column_definition}" - ) - ) - await conn.execute(text(reset_sql)) - - logger.info("Добавлена колонка users.promo_group_auto_assigned") - return True - - except Exception as e: - logger.error(f"Ошибка добавления колонки promo_group_auto_assigned: {e}") - return False - - async def add_welcome_text_is_enabled_column(): column_exists = await check_column_exists('welcome_texts', 'is_enabled') if column_exists: @@ -1586,18 +1511,6 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с настройкой промо групп") - promo_auto_column_added = await add_promo_group_auto_assign_column() - if promo_auto_column_added: - logger.info("✅ Добавлено поле авто-выдачи в промо группах") - else: - logger.warning("⚠️ Не удалось добавить поле авто-выдачи в промо группах") - - user_auto_flag_added = await add_user_promo_group_auto_flag_column() - if user_auto_flag_added: - logger.info("✅ Добавлен флаг auto_assigned у пользователей") - else: - logger.warning("⚠️ Не удалось добавить флаг auto_assigned у пользователей") - logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: @@ -1672,8 +1585,6 @@ async def check_migration_status(): "promo_groups_table": False, "users_promo_group_column": False, "promo_groups_period_discounts_column": False, - "promo_groups_auto_assign_column": False, - "users_promo_group_auto_flag_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1687,8 +1598,6 @@ async def check_migration_status(): status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') - status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_amount_kopeks') - status["users_promo_group_auto_flag_column"] = await check_column_exists('users', 'promo_group_auto_assigned') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -1722,8 +1631,6 @@ async def check_migration_status(): "promo_groups_table": "Таблица промо-групп", "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", - "promo_groups_auto_assign_column": "Колонка auto_assign_amount_kopeks у промо-групп", - "users_promo_group_auto_flag_column": "Флаг auto_assigned у пользователей", } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 567eead2..0546550e 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -1,5 +1,5 @@ import logging -from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +import logging from typing import Dict, Optional from aiogram import Dispatcher, types, F @@ -109,23 +109,6 @@ def _format_period_discounts_value(discounts: Dict[int, int]) -> str: ) -def _format_auto_assign_line(texts, group: PromoGroup) -> Optional[str]: - amount = getattr(group, "auto_assign_amount_kopeks", None) - if not amount: - return None - - return texts.t( - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE", - "🎯 Автовыдача с суммы: {amount}", - ).format(amount=settings.format_price(amount)) - - -def _format_auto_assign_value(amount_kopeks: Optional[int]) -> str: - if not amount_kopeks: - return settings.format_price(0) - return settings.format_price(amount_kopeks) - - def _parse_period_discounts_input(value: str) -> Dict[int, int]: cleaned = (value or "").strip() @@ -157,29 +140,6 @@ def _parse_period_discounts_input(value: str) -> Dict[int, int]: return discounts -def _parse_auto_assign_amount_input(value: str) -> Optional[int]: - cleaned = (value or "").strip() - - if not cleaned: - raise ValueError - - normalized = cleaned.replace(" ", "").replace(",", ".") - - if normalized in {"0", "-", "нет", "off", "disable"}: - return None - - try: - decimal_value = Decimal(normalized) - except (InvalidOperation, ValueError): - raise ValueError - - if decimal_value <= 0: - return None - - kopeks = (decimal_value * Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP) - return int(kopeks) - - async def _prompt_for_period_discounts( message: types.Message, state: FSMContext, @@ -201,27 +161,6 @@ async def _prompt_for_period_discounts( await message.answer(prompt_text) -async def _prompt_for_auto_assign_amount( - message: types.Message, - state: FSMContext, - prompt_key: str, - default_text: str, - *, - current_value: Optional[str] = None, -): - data = await state.get_data() - texts = get_texts(data.get("language", "ru")) - prompt_text = texts.t(prompt_key, default_text) - - if current_value is not None: - try: - prompt_text = prompt_text.format(current=current_value) - except KeyError: - pass - - await message.answer(prompt_text) - - @admin_required @error_handler async def show_promo_groups_menu( @@ -252,18 +191,11 @@ async def show_promo_groups_menu( group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), - ] - - auto_line = _format_auto_assign_line(texts, group) - if auto_line: - group_lines.append(auto_line) - - group_lines.extend([ texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", "Участников: {count}", ).format(count=member_count), - ]) + ] period_lines = _format_period_discounts_lines(texts, group, db_user.language) group_lines.extend(period_lines) @@ -333,18 +265,11 @@ async def show_promo_group_details( "💳 Промогруппа: {name}", ).format(name=group.name), _format_discount_line(texts, group), - ] - - auto_line = _format_auto_assign_line(texts, group) - if auto_line: - lines.append(auto_line) - - lines.append( texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", "Участников: {count}", ).format(count=member_count), - ) + ] period_lines = _format_period_discounts_lines(texts, group, db_user.language) lines.extend(period_lines) @@ -539,39 +464,6 @@ async def process_create_group_period_discounts( ) return - await state.update_data(new_group_period_discounts=period_discounts) - await state.set_state(AdminStates.creating_promo_group_auto_amount) - - await _prompt_for_auto_assign_amount( - message, - state, - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT", - "Введите сумму пополнений (в рублях) для автоматической выдачи. Отправьте 0, если не нужно.", - ) - - -@admin_required -@error_handler -async def process_create_group_auto_amount( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - auto_amount = _parse_auto_assign_amount_input(message.text) - except ValueError: - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN", - "Введите корректную сумму или 0 для отключения.", - ) - ) - return - try: group = await create_promo_group( db, @@ -579,8 +471,7 @@ async def process_create_group_auto_amount( traffic_discount_percent=data["new_group_traffic"], server_discount_percent=data["new_group_servers"], device_discount_percent=data["new_group_devices"], - period_discounts=data.get("new_group_period_discounts"), - auto_assign_amount_kopeks=auto_amount, + period_discounts=period_discounts, ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -756,46 +647,6 @@ async def process_edit_group_period_discounts( await state.clear() return - await state.update_data(edit_group_period_discounts=period_discounts) - await state.set_state(AdminStates.editing_promo_group_auto_amount) - - await _prompt_for_auto_assign_amount( - message, - state, - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT", - "Введите новую сумму (текущая: {current}). Отправьте 0, если без автовыдачи.", - current_value=_format_auto_assign_value(getattr(group, "auto_assign_amount_kopeks", None)), - ) - - -@admin_required -@error_handler -async def process_edit_group_auto_amount( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - auto_amount = _parse_auto_assign_amount_input(message.text) - except ValueError: - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN", - "Введите корректную сумму или 0 для отключения.", - ) - ) - return - - group = await get_promo_group_by_id(db, data.get("edit_group_id")) - if not group: - await message.answer("❌ Промогруппа не найдена") - await state.clear() - return - await update_promo_group( db, group, @@ -803,8 +654,7 @@ async def process_edit_group_auto_amount( traffic_discount_percent=data["edit_group_traffic"], server_discount_percent=data["edit_group_servers"], device_discount_percent=data["edit_group_devices"], - period_discounts=data.get("edit_group_period_discounts"), - auto_assign_amount_kopeks=auto_amount, + period_discounts=period_discounts, ) await state.clear() @@ -978,10 +828,6 @@ def register_handlers(dp: Dispatcher): process_create_group_period_discounts, AdminStates.creating_promo_group_period_discount, ) - dp.message.register( - process_create_group_auto_amount, - AdminStates.creating_promo_group_auto_amount, - ) dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name) dp.message.register( @@ -1000,7 +846,3 @@ def register_handlers(dp: Dispatcher): process_edit_group_period_discounts, AdminStates.editing_promo_group_period_discount, ) - dp.message.register( - process_edit_group_auto_amount, - AdminStates.editing_promo_group_auto_amount, - ) diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 17c39bd7..a761ef5c 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -30,7 +30,6 @@ from app.services.subscription_checkout_service import ( ) from app.services.mulenpay_service import MulenPayService from app.services.pal24_service import Pal24Service, Pal24APIError -from app.services.promo_group_service import maybe_assign_auto_promo_group from app.database.crud.mulenpay import ( create_mulenpay_payment, get_mulenpay_payment_by_local_id, @@ -180,9 +179,7 @@ class PaymentService: logger.error(f"Ошибка обработки реферального пополнения: {e}") else: logger.info(f"❌ Описание '{description_for_referral}' не подходит для реферальной логики") - - await maybe_assign_auto_promo_group(db, user, self.bot) - + if self.bot: try: from app.services.admin_notification_service import AdminNotificationService @@ -464,9 +461,7 @@ class PaymentService: await process_referral_topup(db, user.id, updated_payment.amount_kopeks, self.bot) except Exception as e: logger.error(f"Ошибка обработки реферального пополнения YooKassa: {e}") - - await maybe_assign_auto_promo_group(db, user, self.bot) - + if self.bot: try: from app.services.admin_notification_service import AdminNotificationService @@ -533,8 +528,7 @@ class PaymentService: user = await get_user_by_id(db, payment.user_id) if user: await add_user_balance(db, user, payment.amount_kopeks, f"Пополнение YooKassa: {payment.amount_kopeks//100}₽") - await maybe_assign_auto_promo_group(db, user, self.bot) - + logger.info(f"Успешно обработан платеж YooKassa {payment.yookassa_payment_id}: " f"пользователь {payment.user_id} получил {payment.amount_kopeks/100}₽") @@ -999,8 +993,6 @@ class PaymentService: referral_error, ) - await maybe_assign_auto_promo_group(db, user, self.bot) - await update_mulenpay_payment_status( db, payment=payment, @@ -1190,8 +1182,6 @@ class PaymentService: except Exception as referral_error: logger.error("Ошибка обработки реферального пополнения Pal24: %s", referral_error) - await maybe_assign_auto_promo_group(db, user, self.bot) - if self.bot: try: from app.services.admin_notification_service import AdminNotificationService @@ -1448,9 +1438,7 @@ class PaymentService: await process_referral_topup(db, user.id, amount_kopeks, self.bot) except Exception as e: logger.error(f"Ошибка обработки реферального пополнения CryptoBot: {e}") - - await maybe_assign_auto_promo_group(db, user, self.bot) - + if self.bot: try: from app.services.admin_notification_service import AdminNotificationService diff --git a/app/services/promo_group_service.py b/app/services/promo_group_service.py deleted file mode 100644 index 02ad4dbc..00000000 --- a/app/services/promo_group_service.py +++ /dev/null @@ -1,77 +0,0 @@ -import logging -from datetime import datetime -from typing import Optional - -from aiogram import Bot -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings -from app.database.crud.promo_group import get_auto_assign_promo_group -from app.database.crud.transaction import get_user_total_completed_deposits -from app.database.models import PromoGroup, User -from app.localization.texts import get_texts - -logger = logging.getLogger(__name__) - - -def _format_total_amount(total_amount_kopeks: int) -> str: - return settings.format_price(total_amount_kopeks) - - -async def maybe_assign_auto_promo_group( - db: AsyncSession, - user: User, - bot: Optional[Bot] = None, -) -> Optional[PromoGroup]: - """Назначает промогруппу автоматически при достижении нужной суммы пополнений.""" - try: - if getattr(user, "promo_group_auto_assigned", False): - return None - - total_amount_kopeks = await get_user_total_completed_deposits(db, user.id) - target_group = await get_auto_assign_promo_group(db, total_amount_kopeks) - - if not target_group or target_group.id == user.promo_group_id: - return None - - user.promo_group_id = target_group.id - user.promo_group = target_group - user.promo_group_auto_assigned = True - user.updated_at = datetime.utcnow() - - await db.commit() - await db.refresh(user) - - logger.info( - "Автоматически назначена промогруппа '%s' пользователю %s (сумма пополнений: %s)", - target_group.name, - user.telegram_id, - _format_total_amount(total_amount_kopeks), - ) - - if bot: - try: - texts = get_texts(user.language) - message = texts.t( - "PROMO_GROUP_AUTO_ASSIGN_NOTIFICATION", - "🎉 Вы автоматически переведены в промогруппу «{name}» за пополнения на {amount}.", - ).format(name=target_group.name, amount=_format_total_amount(total_amount_kopeks)) - await bot.send_message(user.telegram_id, message, parse_mode="HTML") - except Exception as notify_error: - logger.error( - "Ошибка отправки уведомления об автоназначении промогруппы пользователю %s: %s", - user.telegram_id, - notify_error, - ) - - return target_group - - except Exception as error: - logger.error( - "Ошибка автоматического назначения промогруппы пользователю %s: %s", - getattr(user, "telegram_id", "unknown"), - error, - exc_info=True, - ) - await db.rollback() - return None diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index 8dd427f4..6d5de2eb 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -14,7 +14,6 @@ from app.database.crud.transaction import ( from app.database.crud.user import get_user_by_telegram_id, add_user_balance from app.external.tribute import TributeService as TributeAPI from app.services.payment_service import PaymentService -from app.services.promo_group_service import maybe_assign_auto_promo_group logger = logging.getLogger(__name__) @@ -140,10 +139,8 @@ class TributeService: if not user.has_made_first_topup: user.has_made_first_topup = True logger.info(f"Отмечен первый топап для пользователя {user_telegram_id}") - - await maybe_assign_auto_promo_group(session, user, self.bot) - - + + try: from app.services.admin_notification_service import AdminNotificationService notification_service = AdminNotificationService(self.bot) @@ -336,13 +333,11 @@ class TributeService: old_balance = user.balance_kopeks user.balance_kopeks += amount_kopeks user.updated_at = datetime.utcnow() - + await session.commit() - - await maybe_assign_auto_promo_group(session, user, self.bot) - + logger.info(f"💰 ПРИНУДИТЕЛЬНО обновлен баланс: {old_balance} -> {user.balance_kopeks} коп") - + await self._send_success_notification(user_id, amount_kopeks) logger.info(f"✅ Принудительно обработан платеж {payment_id}") diff --git a/app/states.py b/app/states.py index 940986d2..0073a4d4 100644 --- a/app/states.py +++ b/app/states.py @@ -69,14 +69,12 @@ class AdminStates(StatesGroup): creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() creating_promo_group_period_discount = State() - creating_promo_group_auto_amount = State() editing_promo_group_name = State() editing_promo_group_traffic_discount = State() editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() editing_promo_group_period_discount = State() - editing_promo_group_auto_amount = State() editing_squad_price = State() editing_traffic_price = State() diff --git a/locales/en.json b/locales/en.json index 51508f0d..419dbe95 100644 --- a/locales/en.json +++ b/locales/en.json @@ -141,7 +141,6 @@ "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "🎯 Auto assignment from: {amount}", "CREATE_TICKET_BUTTON": "🎫 Create ticket", "MY_TICKETS_BUTTON": "📋 My tickets", "CONTACT_SUPPORT_BUTTON": "💬 Contact support", @@ -227,10 +226,8 @@ "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter the top-up amount (in rubles) for automatic assignment. Send 0 to disable.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", - "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a valid amount or 0 to disable.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", @@ -238,7 +235,6 @@ "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter a new amount (current: {current}). Send 0 to disable.", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", @@ -267,7 +263,6 @@ "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%", "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:", "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", - "PROMO_GROUP_AUTO_ASSIGN_NOTIFICATION": "🎉 You have been automatically moved to the promo group “{name}” for topping up {amount}.", "CHANGE_DEVICES_CONFIRM": "\n📱 Confirm change\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", "CHANGE_DEVICES_INFO": "\n📱 Adjust device limit\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 Important:\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n", "CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n", diff --git a/locales/ru.json b/locales/ru.json index ac1c6d0d..e69b9d92 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -19,7 +19,6 @@ "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "🎯 Автовыдача с суммы: {amount}", "CREATE_TICKET_BUTTON": "🎫 Создать тикет", "MY_TICKETS_BUTTON": "📋 Мои тикеты", "CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой", @@ -105,10 +104,8 @@ "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму пополнений (в рублях) для автоматической выдачи. Отправьте 0, если без автовыдачи.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", - "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите корректную сумму или 0 для отключения.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", @@ -116,7 +113,6 @@ "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите новую сумму (текущая: {current}). Отправьте 0, если без автовыдачи.", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", @@ -150,7 +146,6 @@ "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%", "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:", "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", - "PROMO_GROUP_AUTO_ASSIGN_NOTIFICATION": "🎉 Вы автоматически переведены в промогруппу «{name}» за пополнения на {amount}.", "CANCEL": "❌ Отмена", "CHANGE_DEVICES_BUTTON": "📱 Изменить устройства", "CHANGE_DEVICES_CONFIRM": "\n 📱 Подтверждение изменения\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ", From 0d4fd3d6e96dd88f53602a190e8ba4a8a8652f0c Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 06:12:45 +0300 Subject: [PATCH 022/146] Add promo group auto assignment and improved editing --- app/database/crud/promo_group.py | 14 +- app/database/crud/transaction.py | 34 +- app/database/models.py | 4 +- app/database/universal_migration.py | 70 ++++ app/handlers/admin/promo_groups.py | 531 ++++++++++++++++++++++--- app/services/promo_group_assignment.py | 92 +++++ app/states.py | 3 + locales/en.json | 19 +- locales/ru.json | 19 +- 9 files changed, 719 insertions(+), 67 deletions(-) create mode 100644 app/services/promo_group_assignment.py diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index d63c3107..3bc093f2 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -59,15 +59,23 @@ async def create_promo_group( traffic_discount_percent: int, device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, + auto_assign_total_spent_kopeks: Optional[int] = None, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) + auto_assign_total_spent_kopeks = ( + max(0, auto_assign_total_spent_kopeks) + if auto_assign_total_spent_kopeks is not None + else None + ) + promo_group = PromoGroup( name=name.strip(), server_discount_percent=max(0, min(100, server_discount_percent)), traffic_discount_percent=max(0, min(100, traffic_discount_percent)), device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, + auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, is_default=False, ) @@ -76,12 +84,13 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s)", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, normalized_period_discounts, + (auto_assign_total_spent_kopeks or 0) / 100, ) return promo_group @@ -96,6 +105,7 @@ async def update_promo_group( traffic_discount_percent: Optional[int] = None, device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, + auto_assign_total_spent_kopeks: Optional[int] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -108,6 +118,8 @@ async def update_promo_group( if period_discounts is not None: normalized_period_discounts = _normalize_period_discounts(period_discounts) group.period_discounts = normalized_period_discounts or None + if auto_assign_total_spent_kopeks is not None: + group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) await db.commit() await db.refresh(group) diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index b258f1b2..11a2827e 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -37,6 +37,20 @@ async def create_transaction( await db.refresh(transaction) logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}") + + try: + from app.services.promo_group_assignment import ( + maybe_assign_promo_group_by_total_spent, + ) + + await maybe_assign_promo_group_by_total_spent(db, user_id) + except Exception as exc: + logger.debug( + "Не удалось проверить автовыдачу промогруппы для пользователя %s: %s", + user_id, + exc, + ) + return transaction @@ -98,8 +112,26 @@ async def get_user_transactions_count( return result.scalar() +async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int: + result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + and_( + Transaction.user_id == user_id, + Transaction.is_completed.is_(True), + Transaction.type.in_( + [ + TransactionType.DEPOSIT.value, + TransactionType.SUBSCRIPTION_PAYMENT.value, + ] + ), + ) + ) + ) + return int(result.scalar_one()) + + async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction: - + transaction.is_completed = True transaction.completed_at = datetime.utcnow() diff --git a/app/database/models.py b/app/database/models.py index 9cdeaa86..f9b6d8ab 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -271,6 +271,7 @@ class PromoGroup(Base): traffic_discount_percent = Column(Integer, nullable=False, default=0) device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) + auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -335,7 +336,7 @@ class PromoGroup(Base): class User(Base): __tablename__ = "users" - + id = Column(Integer, primary_key=True, index=True) telegram_id = Column(BigInteger, unique=True, index=True, nullable=False) username = Column(String(255), nullable=True) @@ -358,6 +359,7 @@ class User(Base): transactions = relationship("Transaction", back_populates="user") referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user") lifetime_used_traffic_bytes = Column(BigInteger, default=0) + auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) last_remnawave_sync = Column(DateTime, nullable=True) trojan_password = Column(String(255), nullable=True) vless_uuid = Column(String(255), nullable=True) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index ce270f13..40273ff4 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -721,6 +721,39 @@ async def ensure_promo_groups_setup(): logger.info("Добавлена колонка promo_groups.period_discounts") + auto_assign_column_exists = await check_column_exists( + "promo_groups", "auto_assign_total_spent_kopeks" + ) + + if not auto_assign_column_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INTEGER DEFAULT 0" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INTEGER DEFAULT 0" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INT DEFAULT 0" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.auto_assign_total_spent_kopeks: {db_type}" + ) + return False + + logger.info( + "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" + ) + column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -736,6 +769,37 @@ async def ensure_promo_groups_setup(): logger.info("Добавлена колонка users.promo_group_id") + auto_promo_flag_exists = await check_column_exists( + "users", "auto_promo_group_assigned" + ) + + if not auto_promo_flag_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN auto_promo_group_assigned BOOLEAN DEFAULT 0" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN auto_promo_group_assigned BOOLEAN DEFAULT FALSE" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN auto_promo_group_assigned TINYINT(1) DEFAULT 0" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для users.auto_promo_group_assigned: {db_type}" + ) + return False + + logger.info("Добавлена колонка users.auto_promo_group_assigned") + index_exists = await check_index_exists("users", "ix_users_promo_group_id") if not index_exists: @@ -1585,6 +1649,8 @@ async def check_migration_status(): "promo_groups_table": False, "users_promo_group_column": False, "promo_groups_period_discounts_column": False, + "promo_groups_auto_assign_column": False, + "users_auto_promo_group_assigned_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1598,6 +1664,8 @@ async def check_migration_status(): status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') + status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') + status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -1631,6 +1699,8 @@ async def check_migration_status(): "promo_groups_table": "Таблица промо-групп", "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", + "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", + "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 0546550e..917f673f 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -1,6 +1,6 @@ import logging -import logging -from typing import Dict, Optional +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from typing import Dict, Optional, Tuple from aiogram import Dispatcher, types, F from aiogram.fsm.context import FSMContext @@ -161,6 +161,214 @@ async def _prompt_for_period_discounts( await message.answer(prompt_text) +def _format_rubles(amount_kopeks: int) -> str: + if amount_kopeks <= 0: + return "0" + + rubles = Decimal(amount_kopeks) / Decimal(100) + if rubles == rubles.to_integral_value(): + formatted = f"{rubles:,.0f}" + else: + formatted = f"{rubles:,.2f}" + + return formatted.replace(",", " ") + + +def _format_auto_assign_line(texts, group: PromoGroup) -> str: + threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0 + + if threshold <= 0: + return texts.t( + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED", + "Автовыдача по суммарным тратам: отключена", + ) + + amount = _format_rubles(threshold) + return texts.t( + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE", + "Автовыдача по суммарным тратам: от {amount} ₽", + ).format(amount=amount) + + +def _format_auto_assign_value(value_kopeks: Optional[int]) -> str: + if not value_kopeks or value_kopeks <= 0: + return "0" + + rubles = Decimal(value_kopeks) / Decimal(100) + quantized = ( + rubles.quantize(Decimal("1")) + if rubles == rubles.to_integral_value() + else rubles.quantize(Decimal("0.01")) + ) + return str(quantized) + + +def _parse_auto_assign_threshold_input(value: str) -> int: + cleaned = (value or "").strip() + + if not cleaned or cleaned in {"0", "-", "off", "нет"}: + return 0 + + normalized = cleaned.replace(" ", "").replace(",", ".") + + try: + amount = Decimal(normalized) + except InvalidOperation: + raise ValueError + + if amount < 0: + raise ValueError + + kopeks = int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + return max(0, kopeks) + + +async def _prompt_for_auto_assign_threshold( + message: types.Message, + state: FSMContext, + prompt_key: str, + default_text: str, + *, + current_value: Optional[str] = None, +): + data = await state.get_data() + texts = get_texts(data.get("language", "ru")) + prompt_text = texts.t(prompt_key, default_text) + + if current_value is not None: + try: + prompt_text = prompt_text.format(current=current_value) + except KeyError: + pass + + await message.answer(prompt_text) + + +def _build_edit_menu_content( + texts, + group: PromoGroup, + language: str, +) -> Tuple[str, types.InlineKeyboardMarkup]: + header = texts.t( + "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE", + "✏️ Настройки промогруппы «{name}»", + ).format(name=group.name) + + lines = [ + header, + _format_discount_line(texts, group), + _format_auto_assign_line(texts, group), + ] + + period_lines = _format_period_discounts_lines(texts, group, language) + lines.extend(period_lines) + + lines.append( + texts.t( + "ADMIN_PROMO_GROUP_EDIT_MENU_HINT", + "Выберите параметр для изменения:", + ) + ) + + text = "\n".join(line for line in lines if line) + + keyboard_rows = [ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME", + "✏️ Изменить название", + ), + callback_data=f"promo_group_edit_field_{group.id}_name", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC", + "🌐 Скидка на трафик", + ), + callback_data=f"promo_group_edit_field_{group.id}_traffic", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS", + "🖥 Скидка на серверы", + ), + callback_data=f"promo_group_edit_field_{group.id}_servers", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES", + "📱 Скидка на устройства", + ), + callback_data=f"promo_group_edit_field_{group.id}_devices", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS", + "⏳ Скидки по периодам", + ), + callback_data=f"promo_group_edit_field_{group.id}_periods", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN", + "🤖 Автовыдача по тратам", + ), + callback_data=f"promo_group_edit_field_{group.id}_auto", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"promo_group_manage_{group.id}", + ) + ], + ] + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + return text, keyboard + + +def _get_edit_prompt_keyboard(group_id: int, texts) -> types.InlineKeyboardMarkup: + return types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"promo_group_edit_{group_id}", + ) + ] + ] + ) + + +async def _send_edit_menu_after_update( + message: types.Message, + texts, + group: PromoGroup, + language: str, + success_message: Optional[str] = None, +): + menu_text, keyboard = _build_edit_menu_content(texts, group, language) + parts = [part for part in [success_message, menu_text] if part] + + await message.answer( + "\n\n".join(parts), + reply_markup=keyboard, + parse_mode="HTML", + ) + + @admin_required @error_handler async def show_promo_groups_menu( @@ -191,6 +399,7 @@ async def show_promo_groups_menu( group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), + _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", "Участников: {count}", @@ -265,6 +474,7 @@ async def show_promo_group_details( "💳 Промогруппа: {name}", ).format(name=group.name), _format_discount_line(texts, group), + _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", "Участников: {count}", @@ -464,6 +674,39 @@ async def process_create_group_period_discounts( ) return + await state.update_data(new_group_period_discounts=period_discounts) + await state.set_state(AdminStates.creating_promo_group_auto_assign) + + await _prompt_for_auto_assign_threshold( + message, + state, + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT", + "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", + ) + + +@admin_required +@error_handler +async def process_create_group_auto_assign( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + auto_assign_kopeks = _parse_auto_assign_threshold_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN", + "Введите неотрицательное число в рублях или 0 для отключения.", + ) + ) + return + try: group = await create_promo_group( db, @@ -471,7 +714,8 @@ async def process_create_group_period_discounts( traffic_discount_percent=data["new_group_traffic"], server_discount_percent=data["new_group_servers"], device_discount_percent=data["new_group_devices"], - period_discounts=period_discounts, + period_discounts=data.get("new_group_period_discounts"), + auto_assign_total_spent_kopeks=auto_assign_kopeks, ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -513,73 +757,190 @@ async def start_edit_promo_group( return texts = get_texts(db_user.language) - await state.set_state(AdminStates.editing_promo_group_name) await state.update_data(edit_group_id=group.id, language=db_user.language) + await state.set_state(AdminStates.editing_promo_group_menu) + text, keyboard = _build_edit_menu_content(texts, group, db_user.language) await callback.message.edit_text( - texts.t( - "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT", - "Введите новое название промогруппы (текущее: {name}):", - ).format(name=group.name), - reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[ - [types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group.id}")] - ] - ), + text, + reply_markup=keyboard, + parse_mode="HTML", ) await callback.answer() -async def process_edit_group_name(message: types.Message, state: FSMContext): +@admin_required +@error_handler +async def prompt_edit_promo_group_field( + callback: types.CallbackQuery, + db_user, + state: FSMContext, + db: AsyncSession, +): + parts = callback.data.split("_") + if len(parts) < 6: + await callback.answer("❌ Неверная команда", show_alert=True) + return + + group_id = int(parts[4]) + field = parts[5] + + group = await get_promo_group_by_id(db, group_id) + if not group: + await callback.answer("❌ Промогруппа не найдена", show_alert=True) + return + + await state.update_data(edit_group_id=group.id, language=db_user.language) + + texts = get_texts(db_user.language) + reply_markup = _get_edit_prompt_keyboard(group.id, texts) + + if field == "name": + await state.set_state(AdminStates.editing_promo_group_name) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT", + "Введите новое название промогруппы (текущее: {name}):", + ).format(name=group.name) + elif field == "traffic": + await state.set_state(AdminStates.editing_promo_group_traffic_discount) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT", + "Введите новую скидку на трафик (текущее значение: {current}%):", + ).format(current=group.traffic_discount_percent) + elif field == "servers": + await state.set_state(AdminStates.editing_promo_group_server_discount) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT", + "Введите новую скидку на серверы (текущее значение: {current}%):", + ).format(current=group.server_discount_percent) + elif field == "devices": + await state.set_state(AdminStates.editing_promo_group_device_discount) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT", + "Введите новую скидку на устройства (текущее значение: {current}%):", + ).format(current=group.device_discount_percent) + elif field == "periods": + await state.set_state(AdminStates.editing_promo_group_period_discount) + current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT", + "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", + ).format(current=_format_period_discounts_value(current_discounts)) + elif field == "auto": + await state.set_state(AdminStates.editing_promo_group_auto_assign) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT", + "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", + ).format(current=_format_auto_assign_value(group.auto_assign_total_spent_kopeks)) + else: + await callback.answer("❌ Неизвестный параметр", show_alert=True) + return + + await callback.message.edit_text(prompt, reply_markup=reply_markup) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_group_name( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + name = message.text.strip() if not name: - texts = get_texts((await state.get_data()).get("language", "ru")) await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым.")) return - await state.update_data(edit_group_name=name) - await state.set_state(AdminStates.editing_promo_group_traffic_discount) - await _prompt_for_discount( + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group(db, group, name=name) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( message, - state, - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT", - "Введите новую скидку на трафик (0-100):", + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) -async def process_edit_group_traffic(message: types.Message, state: FSMContext): - texts = get_texts((await state.get_data()).get("language", "ru")) +@admin_required +@error_handler +async def process_edit_group_traffic( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + try: value = _validate_percent(message.text) except (ValueError, TypeError): await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) return - await state.update_data(edit_group_traffic=value) - await state.set_state(AdminStates.editing_promo_group_server_discount) - await _prompt_for_discount( + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group(db, group, traffic_discount_percent=value) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( message, - state, - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT", - "Введите новую скидку на серверы (0-100):", + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) -async def process_edit_group_servers(message: types.Message, state: FSMContext): - texts = get_texts((await state.get_data()).get("language", "ru")) +@admin_required +@error_handler +async def process_edit_group_servers( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + try: value = _validate_percent(message.text) except (ValueError, TypeError): await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) return - await state.update_data(edit_group_servers=value) - await state.set_state(AdminStates.editing_promo_group_device_discount) - await _prompt_for_discount( + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group(db, group, server_discount_percent=value) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( message, - state, - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT", - "Введите новую скидку на устройства (0-100):", + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) @@ -600,22 +961,21 @@ async def process_edit_group_devices( await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) return - group = await get_promo_group_by_id(db, data["edit_group_id"]) + group = await get_promo_group_by_id(db, data.get("edit_group_id")) if not group: await message.answer("❌ Промогруппа не найдена") await state.clear() return - await state.update_data(edit_group_devices=devices_discount) - await state.set_state(AdminStates.editing_promo_group_period_discount) + group = await update_promo_group(db, group, device_discount_percent=devices_discount) + await state.set_state(AdminStates.editing_promo_group_menu) - current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) - await _prompt_for_period_discounts( + await _send_edit_menu_after_update( message, - state, - "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT", - "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", - current_value=_format_period_discounts_value(current_discounts), + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) @@ -641,25 +1001,65 @@ async def process_edit_group_period_discounts( ) return - group = await get_promo_group_by_id(db, data["edit_group_id"]) + group = await get_promo_group_by_id(db, data.get("edit_group_id")) if not group: await message.answer("❌ Промогруппа не найдена") await state.clear() return - await update_promo_group( - db, + group = await update_promo_group(db, group, period_discounts=period_discounts) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( + message, + texts, group, - name=data["edit_group_name"], - traffic_discount_percent=data["edit_group_traffic"], - server_discount_percent=data["edit_group_servers"], - device_discount_percent=data["edit_group_devices"], - period_discounts=period_discounts, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) - await state.clear() - await message.answer( - texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name) + +@admin_required +@error_handler +async def process_edit_group_auto_assign( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + auto_assign_kopeks = _parse_auto_assign_threshold_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN", + "Введите неотрицательное число в рублях или 0 для отключения.", + ) + ) + return + + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group( + db, + group, + auto_assign_total_spent_kopeks=auto_assign_kopeks, + ) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( + message, + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), ) @@ -796,7 +1196,14 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups") dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_")) dp.callback_query.register(start_create_promo_group, F.data == "admin_promo_group_create") - dp.callback_query.register(start_edit_promo_group, F.data.startswith("promo_group_edit_")) + dp.callback_query.register( + prompt_edit_promo_group_field, + F.data.startswith("promo_group_edit_field_"), + ) + dp.callback_query.register( + start_edit_promo_group, + F.data.regexp(r"^promo_group_edit_\d+$"), + ) dp.callback_query.register( request_delete_promo_group, F.data.startswith("promo_group_delete_") @@ -828,6 +1235,10 @@ def register_handlers(dp: Dispatcher): process_create_group_period_discounts, AdminStates.creating_promo_group_period_discount, ) + dp.message.register( + process_create_group_auto_assign, + AdminStates.creating_promo_group_auto_assign, + ) dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name) dp.message.register( @@ -846,3 +1257,7 @@ def register_handlers(dp: Dispatcher): process_edit_group_period_discounts, AdminStates.editing_promo_group_period_discount, ) + dp.message.register( + process_edit_group_auto_assign, + AdminStates.editing_promo_group_auto_assign, + ) diff --git a/app/services/promo_group_assignment.py b/app/services/promo_group_assignment.py new file mode 100644 index 00000000..a7437c3c --- /dev/null +++ b/app/services/promo_group_assignment.py @@ -0,0 +1,92 @@ +import logging +from datetime import datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.transaction import get_user_total_spent_kopeks +from app.database.models import PromoGroup, User + +logger = logging.getLogger(__name__) + + +async def _get_best_group_for_spending( + db: AsyncSession, + total_spent_kopeks: int, +) -> Optional[PromoGroup]: + if total_spent_kopeks <= 0: + return None + + result = await db.execute( + select(PromoGroup) + .where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None)) + .where(PromoGroup.auto_assign_total_spent_kopeks > 0) + .order_by(PromoGroup.auto_assign_total_spent_kopeks.desc(), PromoGroup.id.desc()) + ) + groups = result.scalars().all() + + for group in groups: + threshold = group.auto_assign_total_spent_kopeks or 0 + if threshold and total_spent_kopeks >= threshold: + return group + + return None + + +async def maybe_assign_promo_group_by_total_spent( + db: AsyncSession, + user_id: int, +) -> Optional[PromoGroup]: + user = await db.get(User, user_id) + if not user: + logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id) + return None + + if user.auto_promo_group_assigned: + logger.debug( + "Пользователь %s уже получал промогруппу автоматически, пропускаем", user.telegram_id + ) + return None + + total_spent = await get_user_total_spent_kopeks(db, user_id) + if total_spent <= 0: + return None + + target_group = await _get_best_group_for_spending(db, total_spent) + if not target_group: + return None + + try: + previous_group_id = user.promo_group_id + user.auto_promo_group_assigned = True + user.updated_at = datetime.utcnow() + + if target_group.id != previous_group_id: + user.promo_group_id = target_group.id + user.promo_group = target_group + logger.info( + "🤖 Пользователь %s автоматически переведен в промогруппу '%s' за траты %s ₽", + user.telegram_id, + target_group.name, + total_spent / 100, + ) + else: + logger.info( + "🤖 Пользователь %s уже находится в подходящей промогруппе '%s', отмечаем автоприсвоение", + user.telegram_id, + target_group.name, + ) + + await db.commit() + await db.refresh(user) + + return target_group + except Exception as exc: + logger.error( + "Ошибка при автоматическом назначении промогруппы пользователю %s: %s", + user_id, + exc, + ) + await db.rollback() + return None diff --git a/app/states.py b/app/states.py index 0073a4d4..45e87e21 100644 --- a/app/states.py +++ b/app/states.py @@ -69,12 +69,15 @@ class AdminStates(StatesGroup): creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() creating_promo_group_period_discount = State() + creating_promo_group_auto_assign = State() + editing_promo_group_menu = State() editing_promo_group_name = State() editing_promo_group_traffic_discount = State() editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() editing_promo_group_period_discount = State() + editing_promo_group_auto_assign = State() editing_squad_price = State() editing_traffic_price = State() diff --git a/locales/en.json b/locales/en.json index 419dbe95..318249be 100644 --- a/locales/en.json +++ b/locales/en.json @@ -231,11 +231,24 @@ "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100). Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100). Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100). Current value: {current}.", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽", + "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”", + "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:", + "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename", + "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", + "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", diff --git a/locales/ru.json b/locales/ru.json index e69b9d92..8a71c225 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -109,11 +109,24 @@ "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100). Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100). Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100). Текущее значение: {current}.", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽", + "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»", + "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:", + "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название", + "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик", + "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", + "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", + "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", + "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", From 2a92004fbfcc729c0d7d12120f46841e83474053 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 06:24:38 +0300 Subject: [PATCH 023/146] Allow auto promo reassignment for upgrades --- app/services/promo_group_assignment.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/services/promo_group_assignment.py b/app/services/promo_group_assignment.py index a7437c3c..8238c642 100644 --- a/app/services/promo_group_assignment.py +++ b/app/services/promo_group_assignment.py @@ -43,12 +43,6 @@ async def maybe_assign_promo_group_by_total_spent( logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id) return None - if user.auto_promo_group_assigned: - logger.debug( - "Пользователь %s уже получал промогруппу автоматически, пропускаем", user.telegram_id - ) - return None - total_spent = await get_user_total_spent_kopeks(db, user_id) if total_spent <= 0: return None @@ -59,6 +53,15 @@ async def maybe_assign_promo_group_by_total_spent( try: previous_group_id = user.promo_group_id + + if user.auto_promo_group_assigned and target_group.id == previous_group_id: + logger.debug( + "Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется", + user.telegram_id, + target_group.name, + ) + return target_group + user.auto_promo_group_assigned = True user.updated_at = datetime.utcnow() From 6d64ba8ffd07d409b6727f573e2c669da61070a5 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 06:28:11 +0300 Subject: [PATCH 024/146] Run promo group assignment on transaction completion --- app/database/crud/transaction.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index 11a2827e..a7077161 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -134,11 +134,25 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr transaction.is_completed = True transaction.completed_at = datetime.utcnow() - + await db.commit() await db.refresh(transaction) - + logger.info(f"✅ Транзакция {transaction.id} завершена") + + try: + from app.services.promo_group_assignment import ( + maybe_assign_promo_group_by_total_spent, + ) + + await maybe_assign_promo_group_by_total_spent(db, transaction.user_id) + except Exception as exc: + logger.debug( + "Не удалось проверить автовыдачу промогруппы для пользователя %s: %s", + transaction.user_id, + exc, + ) + return transaction From 18a7d179f8cdd55dc9d21902dae1b4ee57cfbaf0 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 06:39:06 +0300 Subject: [PATCH 025/146] Enhance add-on top-up flow with preset amounts --- app/handlers/balance.py | 76 ++++++++++ app/handlers/subscription.py | 247 ++++++++++++++++++++++++++++--- app/keyboards/inline.py | 125 +++++++++------- app/localization/locales/en.json | 1 + app/localization/locales/ru.json | 1 + locales/en.json | 1 + locales/ru.json | 1 + 7 files changed, 377 insertions(+), 75 deletions(-) diff --git a/app/handlers/balance.py b/app/handlers/balance.py index 6ed27777..d27c5a5e 100644 --- a/app/handlers/balance.py +++ b/app/handlers/balance.py @@ -1529,6 +1529,77 @@ async def handle_quick_amount_selection( await callback.answer("❌ Ошибка обработки запроса", show_alert=True) +@error_handler +async def handle_topup_amount_callback( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + try: + _, method, amount_str = callback.data.split("|", 2) + amount_kopeks = int(amount_str) + except ValueError: + await callback.answer("❌ Некорректный запрос", show_alert=True) + return + + if amount_kopeks <= 0: + await callback.answer("❌ Некорректная сумма", show_alert=True) + return + + try: + if method == "yookassa": + from app.database.database import AsyncSessionLocal + + async with AsyncSessionLocal() as db: + await process_yookassa_payment_amount( + callback.message, db_user, db, amount_kopeks, state + ) + elif method == "yookassa_sbp": + from app.database.database import AsyncSessionLocal + + async with AsyncSessionLocal() as db: + await process_yookassa_sbp_payment_amount( + callback.message, db_user, db, amount_kopeks, state + ) + elif method == "mulenpay": + from app.database.database import AsyncSessionLocal + + async with AsyncSessionLocal() as db: + await process_mulenpay_payment_amount( + callback.message, db_user, db, amount_kopeks, state + ) + elif method == "pal24": + from app.database.database import AsyncSessionLocal + + async with AsyncSessionLocal() as db: + await process_pal24_payment_amount( + callback.message, db_user, db, amount_kopeks, state + ) + elif method == "cryptobot": + from app.database.database import AsyncSessionLocal + + async with AsyncSessionLocal() as db: + await process_cryptobot_payment_amount( + callback.message, db_user, db, amount_kopeks, state + ) + elif method == "stars": + await process_stars_payment_amount( + callback.message, db_user, amount_kopeks, state + ) + elif method == "tribute": + await start_tribute_payment(callback, db_user) + return + else: + await callback.answer("❌ Неизвестный способ оплаты", show_alert=True) + return + + await callback.answer() + + except Exception as error: + logger.error(f"Ошибка быстрого пополнения: {error}") + await callback.answer("❌ Ошибка обработки запроса", show_alert=True) + + def register_handlers(dp: Dispatcher): dp.callback_query.register( @@ -1631,3 +1702,8 @@ def register_handlers(dp: Dispatcher): handle_quick_amount_selection, F.data.startswith("quick_amount_") ) + + dp.callback_query.register( + handle_topup_amount_callback, + F.data.startswith("topup_amount|") + ) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 4e56123e..3f0c182a 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -964,7 +964,10 @@ async def save_cart_and_redirect_to_topup( f"🛒 Ваша корзина сохранена!\n" f"После пополнения баланса вы сможете вернуться к оформлению подписки.\n\n" f"Выберите способ пополнения:", - reply_markup=get_payment_methods_keyboard_with_cart(db_user.language), + reply_markup=get_payment_methods_keyboard_with_cart( + db_user.language, + missing_amount, + ), parse_mode="HTML" ) @@ -990,7 +993,10 @@ async def return_to_saved_cart( f"Требуется: {texts.format_price(total_price)}\n" f"У вас: {texts.format_price(db_user.balance_kopeks)}\n" f"Не хватает: {texts.format_price(missing_amount)}", - reply_markup=get_insufficient_balance_keyboard_with_cart(db_user.language) + reply_markup=get_insufficient_balance_keyboard_with_cart( + db_user.language, + missing_amount, + ) ) return @@ -1225,10 +1231,33 @@ async def apply_countries_changes( logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽") if total_cost > 0 and db_user.balance_kopeks < total_cost: - await callback.answer( - f"⚠️ Недостаточно средств!\nТребуется: {texts.format_price(total_cost)} (за {charged_months} мес)\nУ вас: {texts.format_price(db_user.balance_kopeks)}", - show_alert=True + missing_kopeks = total_cost - db_user.balance_kopeks + required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)" + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=required_text, + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), ) + + await callback.message.answer( + message_text, + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + amount_kopeks=missing_kopeks, + ), + parse_mode="HTML", + ) + await callback.answer() return try: @@ -1415,10 +1444,32 @@ async def confirm_change_devices( price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) if price > 0 and db_user.balance_kopeks < price: - await callback.answer( - f"⚠️ Недостаточно средств!\nТребуется: {texts.format_price(price)} (за {charged_months} мес)\nУ вас: {texts.format_price(db_user.balance_kopeks)}", - show_alert=True + missing_kopeks = price - db_user.balance_kopeks + required_text = f"{texts.format_price(price)} (за {charged_months} мес)" + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=required_text, + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), ) + + await callback.message.answer( + message_text, + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + amount_kopeks=missing_kopeks, + ), + parse_mode="HTML", + ) + await callback.answer() return action_text = f"увеличить до {new_devices_count}" @@ -1989,6 +2040,8 @@ async def confirm_add_devices( devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription + + resume_callback = None new_total_devices = subscription.device_limit + devices_count @@ -2007,12 +2060,30 @@ async def confirm_add_devices( if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks + required_text = f"{texts.format_price(price)} (за {charged_months} мес)" + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=required_text, + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), + message_text, reply_markup=get_insufficient_balance_keyboard( db_user.language, resume_callback=resume_callback, + amount_kopeks=missing_kopeks, ), + parse_mode="HTML", ) await callback.answer() return @@ -2204,9 +2275,29 @@ async def confirm_extend_subscription( if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks + required_text = texts.format_price(price) + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=required_text, + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + message_text, + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + amount_kopeks=missing_kopeks, + ), + parse_mode="HTML", ) await callback.answer() return @@ -2320,13 +2411,32 @@ async def confirm_reset_traffic( texts = get_texts(db_user.language) subscription = db_user.subscription - reset_price = PERIOD_PRICES[30] - + reset_price = PERIOD_PRICES[30] + if db_user.balance_kopeks < reset_price: missing_kopeks = reset_price - db_user.balance_kopeks + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=texts.format_price(reset_price), + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + message_text, + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + amount_kopeks=missing_kopeks, + ), + parse_mode="HTML", ) await callback.answer() return @@ -2961,12 +3071,29 @@ async def confirm_purchase( if db_user.balance_kopeks < final_price: missing_kopeks = final_price - db_user.balance_kopeks + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=texts.format_price(final_price), + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), + message_text, reply_markup=get_insufficient_balance_keyboard( db_user.language, resume_callback=resume_callback, + amount_kopeks=missing_kopeks, ), + parse_mode="HTML", ) await callback.answer() return @@ -2981,12 +3108,29 @@ async def confirm_purchase( if not success: missing_kopeks = final_price - db_user.balance_kopeks + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=texts.format_price(final_price), + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), + message_text, reply_markup=get_insufficient_balance_keyboard( db_user.language, resume_callback=resume_callback, + amount_kopeks=missing_kopeks, ), + parse_mode="HTML", ) await callback.answer() return @@ -3232,9 +3376,28 @@ async def add_traffic( if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=texts.format_price(price), + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + message_text, + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + amount_kopeks=missing_kopeks, + ), + parse_mode="HTML", ) await callback.answer() return @@ -3689,9 +3852,28 @@ async def confirm_add_countries_to_subscription( if new_countries and db_user.balance_kopeks < total_price: missing_kopeks = total_price - db_user.balance_kopeks + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=texts.format_price(total_price), + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + message_text, + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + amount_kopeks=missing_kopeks, + ), + parse_mode="HTML", ) await state.clear() await callback.answer() @@ -4355,9 +4537,28 @@ async def confirm_switch_traffic( if db_user.balance_kopeks < total_price_difference: missing_kopeks = total_price_difference - db_user.balance_kopeks + message_text = texts.t( + "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + ( + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" + "Выберите способ пополнения. Сумма подставится автоматически." + ), + ).format( + required=f"{texts.format_price(total_price_difference)} (за {months_remaining} мес)", + balance=texts.format_price(db_user.balance_kopeks), + missing=texts.format_price(missing_kopeks), + ) + await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + message_text, + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + amount_kopeks=missing_kopeks, + ), + parse_mode="HTML", ) await callback.answer() return diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 0b4b5ab2..53fa2f62 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -270,29 +270,36 @@ def get_server_status_keyboard( def get_insufficient_balance_keyboard( language: str = DEFAULT_LANGUAGE, resume_callback: str | None = None, - ) -> InlineKeyboardMarkup: + amount_kopeks: int | None = None, +) -> InlineKeyboardMarkup: texts = get_texts(language) - keyboard: list[list[InlineKeyboardButton]] = [ - [ - InlineKeyboardButton( - text=texts.GO_TO_BALANCE_TOP_UP, - callback_data="balance_topup", - ) - ] - ] + keyboard = get_payment_methods_keyboard(amount_kopeks or 0, language) if resume_callback: - keyboard.append([ - InlineKeyboardButton( - text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data=resume_callback, + keyboard.inline_keyboard.insert( + 0, + [ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data=resume_callback, + ) + ], + ) + + if keyboard.inline_keyboard: + last_row = keyboard.inline_keyboard[-1] + if ( + len(last_row) == 1 + and isinstance(last_row[0], InlineKeyboardButton) + and last_row[0].callback_data == "menu_balance" + ): + keyboard.inline_keyboard[-1][0] = InlineKeyboardButton( + text=last_row[0].text, + callback_data="back_to_menu", ) - ]) - keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]) - - return InlineKeyboardMarkup(inline_keyboard=keyboard) + return keyboard def get_subscription_keyboard( @@ -367,8 +374,11 @@ def get_subscription_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_payment_methods_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup: - keyboard = get_payment_methods_keyboard(0, language) +def get_payment_methods_keyboard_with_cart( + language: str = "ru", + amount_kopeks: int = 0, +) -> InlineKeyboardMarkup: + keyboard = get_payment_methods_keyboard(amount_kopeks, language) # Добавляем кнопку "Очистить корзину" keyboard.inline_keyboard.append([ @@ -396,21 +406,26 @@ def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineK )] ]) -def get_insufficient_balance_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup: - return InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton( - text="💰 Пополнить баланс", - callback_data="balance_topup" - )], - [InlineKeyboardButton( - text="🗑️ Очистить корзину", - callback_data="clear_saved_cart" - )], - [InlineKeyboardButton( - text="🔙 Назад", - callback_data="back_to_menu" - )] - ]) +def get_insufficient_balance_keyboard_with_cart( + language: str = "ru", + amount_kopeks: int = 0, +) -> InlineKeyboardMarkup: + keyboard = get_insufficient_balance_keyboard( + language, + amount_kopeks=amount_kopeks, + ) + + keyboard.inline_keyboard.insert( + 0, + [ + InlineKeyboardButton( + text="🗑️ Очистить корзину и вернуться", + callback_data="clear_saved_cart", + ) + ], + ) + + return keyboard def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) @@ -624,29 +639,35 @@ def get_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - - + + amount_kopeks = max(0, int(amount_kopeks or 0)) + + def _build_callback(method: str) -> str: + if amount_kopeks > 0: + return f"topup_amount|{method}|{amount_kopeks}" + return f"topup_{method}" + if settings.TELEGRAM_STARS_ENABLED: keyboard.append([ InlineKeyboardButton( - text=texts.t("PAYMENT_TELEGRAM_STARS", "⭐ Telegram Stars"), - callback_data="topup_stars" + text=texts.t("PAYMENT_TELEGRAM_STARS", "⭐ Telegram Stars"), + callback_data=_build_callback("stars") ) ]) if settings.is_yookassa_enabled(): keyboard.append([ InlineKeyboardButton( - text=texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)"), - callback_data="topup_yookassa" + text=texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)"), + callback_data=_build_callback("yookassa") ) ]) - + if settings.YOOKASSA_SBP_ENABLED: keyboard.append([ InlineKeyboardButton( - text=texts.t("PAYMENT_SBP_YOOKASSA", "🏦 Оплатить по СБП (YooKassa)"), - callback_data="topup_yookassa_sbp" + text=texts.t("PAYMENT_SBP_YOOKASSA", "🏦 Оплатить по СБП (YooKassa)"), + callback_data=_build_callback("yookassa_sbp") ) ]) @@ -654,7 +675,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN keyboard.append([ InlineKeyboardButton( text=texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)"), - callback_data="topup_tribute" + callback_data=_build_callback("tribute") ) ]) @@ -662,7 +683,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN keyboard.append([ InlineKeyboardButton( text=texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)"), - callback_data="topup_mulenpay" + callback_data=_build_callback("mulenpay") ) ]) @@ -670,7 +691,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN keyboard.append([ InlineKeyboardButton( text=texts.t("PAYMENT_CARD_PAL24", "💳 Банковская карта (PayPalych)"), - callback_data="topup_pal24" + callback_data=_build_callback("pal24") ) ]) @@ -678,29 +699,29 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN keyboard.append([ InlineKeyboardButton( text=texts.t("PAYMENT_CRYPTOBOT", "🪙 Криптовалюта (CryptoBot)"), - callback_data="topup_cryptobot" + callback_data=_build_callback("cryptobot") ) ]) keyboard.append([ InlineKeyboardButton( - text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"), + text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"), callback_data="topup_support" ) ]) - - if len(keyboard) == 1: + + if len(keyboard) == 1: keyboard.insert(0, [ InlineKeyboardButton( text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"), callback_data="payment_methods_unavailable" ) ]) - + keyboard.append([ InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance") ]) - + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_yookassa_payment_keyboard( diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 5a9077ac..8fba5d67 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -37,6 +37,7 @@ "GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up", "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout", "INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Insufficient funds\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.", "LANGUAGE_SELECTED": "🌐 Interface language set: English", "LOADING": "⏳ Loading...", "MAIN_MENU": "👤 {user_name}\n\n📱 Subscription: {subscription_status}\n\nChoose an option:\n", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 210b93ff..d51eee5b 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -123,6 +123,7 @@ "GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса", "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки", "INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n Пополните баланс на {amount} и попробуйте снова.\n ", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Недостаточно средств\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.", "INVALID_AMOUNT": "❌ Неверная сумма", "LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: Русский", "LOADING": "⏳ Загрузка...", diff --git a/locales/en.json b/locales/en.json index 318249be..40656662 100644 --- a/locales/en.json +++ b/locales/en.json @@ -37,6 +37,7 @@ "GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up", "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout", "INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Insufficient funds\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.", "LANGUAGE_SELECTED": "🌐 Interface language set: English", "LOADING": "⏳ Loading...", "MAIN_MENU": "👤 {user_name}\n\n📱 Subscription: {subscription_status}\n\nChoose an option:\n", diff --git a/locales/ru.json b/locales/ru.json index 8a71c225..eb8d1ea4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -200,6 +200,7 @@ "GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса", "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки", "INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n Пополните баланс на {amount} и попробуйте снова.\n ", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Недостаточно средств\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.", "INVALID_AMOUNT": "❌ Неверная сумма", "LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: Русский", "LOADING": "⏳ Загрузка...", From 952face2ae7966029b32c5fab7a45e1feb9866a0 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 07:24:10 +0300 Subject: [PATCH 026/146] Add automated admin reports --- .env.example | 2 + README.md | 6 + app/bot.py | 2 + app/config.py | 23 ++ app/handlers/admin/main.py | 25 +- app/handlers/admin/reports.py | 95 +++++++ app/keyboards/admin.py | 12 + app/services/admin_notification_service.py | 28 +- app/services/report_service.py | 284 +++++++++++++++++++++ locales/en.json | 7 + locales/ru.json | 7 + main.py | 18 ++ 12 files changed, 504 insertions(+), 5 deletions(-) create mode 100644 app/handlers/admin/reports.py create mode 100644 app/services/report_service.py diff --git a/.env.example b/.env.example index 2a801dfb..c78d8a82 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ ADMIN_NOTIFICATIONS_ENABLED=true ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ! ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов +ADMIN_REPORTS_TOPIC_ID=130 # Опционально: отдельный ID топика для отчетов +ADMIN_REPORTS_TIME_MOSCOW=09:00 # Время ежедневного отчета (МСК) # Обязательная подписка на канал CHANNEL_SUB_ID= # Опционально ID твоего канала (-100) CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал diff --git a/README.md b/README.md index 7f65c08a..87da0ea8 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,8 @@ ADMIN_NOTIFICATIONS_ENABLED=true ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ! ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов +ADMIN_REPORTS_TOPIC_ID=130 # Опционально: отдельный ID топика для отчетов +ADMIN_REPORTS_TIME_MOSCOW=09:00 # Время ежедневного отчета (МСК) # Обязательная подписка на канал CHANNEL_SUB_ID= # Опционально ID твоего канала (-100) CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал @@ -969,8 +971,12 @@ docker compose down -v --remove-orphans ADMIN_NOTIFICATIONS_ENABLED=true ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # ID канала/группы ADMIN_NOTIFICATIONS_TOPIC_ID=123 # ID топика (опционально) +ADMIN_REPORTS_TOPIC_ID=130 # ID топика для отчетов (опционально) +ADMIN_REPORTS_TIME_MOSCOW=09:00 # Время ежедневного отчета (МСК) ``` +> ⚙️ Бот автоматически отправит ежедневный отчет за предыдущие сутки в указанное время (по МСК). Если `ADMIN_REPORTS_TOPIC_ID` не задан, отчеты будут приходить в основной топик уведомлений. В админ-панели доступен раздел «Отчеты» для ручной отправки ежедневных, недельных и месячных сводок. + #### 2. Создание канала 1. **Создайте приватный канал** или группу для уведомлений diff --git a/app/bot.py b/app/bot.py index c23bf06d..a13e2237 100644 --- a/app/bot.py +++ b/app/bot.py @@ -38,6 +38,7 @@ from app.handlers.admin import ( backup as admin_backup, welcome_text as admin_welcome_text, tickets as admin_tickets, + reports as admin_reports, ) from app.handlers.stars_payments import register_stars_handlers @@ -139,6 +140,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_backup.register_handlers(dp) admin_welcome_text.register_welcome_text_handlers(dp) admin_tickets.register_handlers(dp) + admin_reports.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/config.py b/app/config.py index 8c8e6f17..811c9999 100644 --- a/app/config.py +++ b/app/config.py @@ -2,6 +2,7 @@ import os import re import html from collections import defaultdict +from datetime import time as dt_time from typing import List, Optional, Union, Dict from pydantic_settings import BaseSettings from pydantic import field_validator, Field @@ -26,6 +27,8 @@ class Settings(BaseSettings): ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None + ADMIN_REPORTS_TOPIC_ID: Optional[int] = None + ADMIN_REPORTS_TIME_MOSCOW: str = "09:00" CHANNEL_SUB_ID: Optional[str] = None CHANNEL_LINK: Optional[str] = None @@ -637,6 +640,26 @@ class Settings(BaseSettings): return (self.ADMIN_NOTIFICATIONS_ENABLED and self.get_admin_notifications_chat_id() is not None) + def get_admin_reports_topic_id(self) -> Optional[int]: + if not self.ADMIN_REPORTS_TOPIC_ID: + return None + + try: + return int(self.ADMIN_REPORTS_TOPIC_ID) + except (ValueError, TypeError): + return None + + def get_admin_reports_time(self) -> dt_time: + raw_value = (self.ADMIN_REPORTS_TIME_MOSCOW or "09:00").strip() + + try: + hours_str, minutes_str = raw_value.split(":", maxsplit=1) + hours = max(0, min(23, int(hours_str))) + minutes = max(0, min(59, int(minutes_str))) + return dt_time(hour=hours, minute=minutes) + except (ValueError, AttributeError): + return dt_time(hour=9, minute=0) + def get_backup_send_chat_id(self) -> Optional[int]: if not self.BACKUP_SEND_CHAT_ID: return None diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py index a26fd7b3..ac86a658 100644 --- a/app/handlers/admin/main.py +++ b/app/handlers/admin/main.py @@ -12,7 +12,8 @@ from app.keyboards.admin import ( get_admin_communications_submenu_keyboard, get_admin_support_submenu_keyboard, get_admin_settings_submenu_keyboard, - get_admin_system_submenu_keyboard + get_admin_system_submenu_keyboard, + get_admin_reports_keyboard, ) from app.localization.texts import get_texts from app.handlers.admin import support_settings as support_settings_handlers @@ -144,6 +145,23 @@ async def show_support_submenu( await callback.answer() +@admin_required +@error_handler +async def show_reports_submenu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + texts = get_texts(db_user.language) + + await callback.message.edit_text( + f"📈 **{texts.ADMIN_REPORTS}**\n\n" + texts.ADMIN_REPORTS_MENU_HINT, + reply_markup=get_admin_reports_keyboard(db_user.language), + parse_mode="Markdown" + ) + await callback.answer() + + # Moderator panel entry (from main menu quick button) async def show_moderator_panel( callback: types.CallbackQuery, @@ -406,6 +424,11 @@ def register_handlers(dp: Dispatcher): show_support_audit, F.data.in_(["admin_support_audit"]) | F.data.startswith("admin_support_audit_page_") ) + + dp.callback_query.register( + show_reports_submenu, + F.data == "admin_submenu_reports" + ) dp.callback_query.register( show_settings_submenu, diff --git a/app/handlers/admin/reports.py b/app/handlers/admin/reports.py new file mode 100644 index 00000000..d6f795e6 --- /dev/null +++ b/app/handlers/admin/reports.py @@ -0,0 +1,95 @@ +import logging + +from aiogram import Dispatcher, types, F +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User +from app.localization.texts import get_texts +from app.services.report_service import report_service, ReportPeriod +from app.utils.decorators import admin_required, error_handler + + +logger = logging.getLogger(__name__) + + +async def _send_report( + callback: types.CallbackQuery, + db_user: User, + period: ReportPeriod, + success_message: str, + error_message: str, +): + success, _ = await report_service.send_report(period) + + if success: + logger.info("Админ %s отправил отчет %s", db_user.id, period.value) + await callback.answer(success_message) + else: + logger.error("Не удалось отправить отчет %s по запросу админа %s", period.value, db_user.id) + await callback.answer(error_message, show_alert=True) + + +@admin_required +@error_handler +async def send_daily_report( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + await _send_report( + callback, + db_user, + ReportPeriod.DAILY, + texts.ADMIN_REPORTS_SENT, + texts.ADMIN_REPORTS_ERROR, + ) + + +@admin_required +@error_handler +async def send_weekly_report( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + await _send_report( + callback, + db_user, + ReportPeriod.WEEKLY, + texts.ADMIN_REPORTS_SENT, + texts.ADMIN_REPORTS_ERROR, + ) + + +@admin_required +@error_handler +async def send_monthly_report( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + await _send_report( + callback, + db_user, + ReportPeriod.MONTHLY, + texts.ADMIN_REPORTS_SENT, + texts.ADMIN_REPORTS_ERROR, + ) + + +def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register( + send_daily_report, + F.data == "admin_report_daily", + ) + dp.callback_query.register( + send_weekly_report, + F.data == "admin_report_weekly", + ) + dp.callback_query.register( + send_monthly_report, + F.data == "admin_report_monthly", + ) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 7b5f1549..e3c41abf 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -10,6 +10,7 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")], [InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")], + [InlineKeyboardButton(text=texts.ADMIN_REPORTS, callback_data="admin_submenu_reports")], [InlineKeyboardButton(text="🛟 Поддержка", callback_data="admin_submenu_support")], [InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")], [InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")], @@ -91,6 +92,17 @@ def get_admin_support_submenu_keyboard(language: str = "ru") -> InlineKeyboardMa ]) +def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.ADMIN_REPORTS_DAILY, callback_data="admin_report_daily")], + [InlineKeyboardButton(text=texts.ADMIN_REPORTS_WEEKLY, callback_data="admin_report_weekly")], + [InlineKeyboardButton(text=texts.ADMIN_REPORTS_MONTHLY, callback_data="admin_report_monthly")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")] + ]) + + def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 1882e35b..f817c40a 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -20,6 +20,7 @@ class AdminNotificationService: self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None) self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None) self.ticket_topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID', None) + self.reports_topic_id = settings.get_admin_reports_topic_id() self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str: @@ -306,11 +307,18 @@ class AdminNotificationService: logger.error(f"Ошибка отправки уведомления о продлении: {e}") return False - async def _send_message(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False) -> bool: + async def _send_message( + self, + text: str, + reply_markup: types.InlineKeyboardMarkup | None = None, + *, + ticket_event: bool = False, + topic_id: Optional[int] = None + ) -> bool: if not self.chat_id: logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен") return False - + try: message_kwargs = { 'chat_id': self.chat_id, @@ -321,7 +329,9 @@ class AdminNotificationService: # route to ticket-specific topic if provided thread_id = None - if ticket_event and self.ticket_topic_id: + if topic_id: + thread_id = topic_id + elif ticket_event and self.ticket_topic_id: thread_id = self.ticket_topic_id elif self.topic_id: thread_id = self.topic_id @@ -329,7 +339,7 @@ class AdminNotificationService: message_kwargs['message_thread_id'] = thread_id if reply_markup is not None: message_kwargs['reply_markup'] = reply_markup - + await self.bot.send_message(**message_kwargs) logger.info(f"Уведомление отправлено в чат {self.chat_id}") return True @@ -346,6 +356,16 @@ class AdminNotificationService: def _is_enabled(self) -> bool: return self.enabled and bool(self.chat_id) + + def is_enabled(self) -> bool: + return self._is_enabled() + + async def send_report_message(self, text: str, *, topic_id: Optional[int] = None) -> bool: + if not self._is_enabled(): + return False + + effective_topic = topic_id or self.reports_topic_id or self.topic_id + return await self._send_message(text, topic_id=effective_topic) def _get_payment_method_display(self, payment_method: Optional[str]) -> str: method_names = { diff --git a/app/services/report_service.py b/app/services/report_service.py new file mode 100644 index 00000000..45735ff7 --- /dev/null +++ b/app/services/report_service.py @@ -0,0 +1,284 @@ +import asyncio +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta +from enum import Enum +from typing import Optional, Tuple +from zoneinfo import ZoneInfo + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.database import AsyncSessionLocal +from app.database.models import ( + Subscription, + SubscriptionStatus, + Transaction, + TransactionType, +) +from app.services.admin_notification_service import AdminNotificationService + + +logger = logging.getLogger(__name__) + + +class ReportPeriod(Enum): + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY = "monthly" + + +@dataclass +class ReportPeriodInfo: + start_msk: datetime + end_msk: datetime + title: str + caption: str + range_caption: str + emoji: str + + +class ReportService: + def __init__(self) -> None: + self.notification_service: Optional[AdminNotificationService] = None + self._task: Optional[asyncio.Task] = None + self._stop_event = asyncio.Event() + self._moscow_tz = ZoneInfo("Europe/Moscow") + self._utc_tz = ZoneInfo("UTC") + + def set_notification_service(self, service: AdminNotificationService) -> None: + self.notification_service = service + + async def start(self) -> Optional[asyncio.Task]: + if self._task and not self._task.done(): + return self._task + + if not self.notification_service or not self.notification_service.is_enabled(): + logger.info("Сервис отчетов не запущен: админ-уведомления отключены или не настроен чат") + return None + + self._stop_event.clear() + self._task = asyncio.create_task(self._scheduler_loop()) + logger.info("Сервис отчетов запущен") + return self._task + + async def stop(self) -> None: + if not self._task: + return + + self._stop_event.set() + try: + await self._task + finally: + self._task = None + self._stop_event.clear() + logger.info("Сервис отчетов остановлен") + + async def send_report(self, period: ReportPeriod) -> Tuple[bool, str]: + text, _ = await self.generate_report(period) + + if not text: + return False, text + + if not self.notification_service or not self.notification_service.is_enabled(): + logger.warning("Отчет не отправлен: сервис админ-уведомлений недоступен") + return False, text + + success = await self.notification_service.send_report_message(text) + if success: + logger.info("Отчет %s отправлен", period.value) + else: + logger.error("Не удалось отправить отчет %s", period.value) + return success, text + + async def generate_report(self, period: ReportPeriod) -> Tuple[str, dict]: + info = self._get_period_info(period) + if not info: + return "", {} + + start_utc = info.start_msk.astimezone(self._utc_tz).replace(tzinfo=None) + end_utc = info.end_msk.astimezone(self._utc_tz).replace(tzinfo=None) + + async with AsyncSessionLocal() as session: + stats = await self._collect_stats(session, start_utc, end_utc) + + text = self._format_report(info, stats) + return text, stats + + async def _scheduler_loop(self) -> None: + while not self._stop_event.is_set(): + next_run = self._get_next_run_datetime() + now_utc = datetime.now(self._utc_tz) + wait_seconds = max(0, (next_run - now_utc).total_seconds()) + + try: + await asyncio.wait_for(self._stop_event.wait(), timeout=wait_seconds) + break + except asyncio.TimeoutError: + pass + + if self._stop_event.is_set(): + break + + try: + await self.send_report(ReportPeriod.DAILY) + except Exception as error: # pragma: no cover - defensive logging + logger.error("Ошибка отправки ежедневного отчета: %s", error, exc_info=True) + + async def _collect_stats(self, session: AsyncSession, start: datetime, end: datetime) -> dict: + now_utc = datetime.utcnow() + + total_trials_query = select(func.count()).select_from(Subscription).where( + Subscription.is_trial.is_(True), + Subscription.end_date > now_utc, + Subscription.status.in_([ + SubscriptionStatus.ACTIVE.value, + SubscriptionStatus.TRIAL.value, + ]), + ) + total_trials = (await session.scalar(total_trials_query)) or 0 + + total_paid_query = select(func.count()).select_from(Subscription).where( + Subscription.is_trial.is_(False), + Subscription.end_date > now_utc, + Subscription.status == SubscriptionStatus.ACTIVE.value, + ) + total_paid = (await session.scalar(total_paid_query)) or 0 + + new_trials_query = select(func.count()).select_from(Subscription).where( + Subscription.is_trial.is_(True), + Subscription.start_date >= start, + Subscription.start_date < end, + ) + new_trials = (await session.scalar(new_trials_query)) or 0 + + new_paid_query = select(func.count()).select_from(Subscription).where( + Subscription.is_trial.is_(False), + Subscription.start_date >= start, + Subscription.start_date < end, + ) + new_paid = (await session.scalar(new_paid_query)) or 0 + + payments_query = select( + func.count(Transaction.id), + func.coalesce(func.sum(Transaction.amount_kopeks), 0), + ).where( + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed.is_(True), + Transaction.created_at >= start, + Transaction.created_at < end, + ) + payments_count, payments_sum = (await session.execute(payments_query)).one() + + return { + "total_trials": int(total_trials), + "total_paid": int(total_paid), + "new_trials": int(new_trials), + "new_paid": int(new_paid), + "payments_count": int(payments_count or 0), + "payments_sum": int(payments_sum or 0), + "period_start": start, + "period_end": end, + } + + def _format_report(self, info: ReportPeriodInfo, stats: dict) -> str: + now_msk = datetime.now(self._moscow_tz) + end_display = info.end_msk - timedelta(seconds=1) + period_range = ( + f"{info.start_msk.strftime('%d.%m.%Y %H:%M')} — " + f"{end_display.strftime('%d.%m.%Y %H:%M')}" + ) + + lines = [ + f"{info.emoji} {info.title} ({info.caption})", + "", + "🎯 Триалы", + f"• Активных сейчас: {stats['total_trials']}", + f"• Новых за период: {stats['new_trials']}", + "", + "💎 Платные подписки", + f"• Активных сейчас: {stats['total_paid']}", + f"• Новых за период: {stats['new_paid']}", + "", + "💳 Пополнения", + f"• Количество платежей: {stats['payments_count']}", + f"• Сумма: {settings.format_price(stats['payments_sum'])}", + "", + f"🕒 Период (МСК): {period_range}", + f"📅 Сформировано: {now_msk.strftime('%d.%m.%Y %H:%M')}", + ] + + return "\n".join(lines) + + def _get_period_info(self, period: ReportPeriod) -> Optional[ReportPeriodInfo]: + now_msk = datetime.now(self._moscow_tz) + + if period is ReportPeriod.DAILY: + target_date = now_msk.date() - timedelta(days=1) + start_msk = datetime.combine(target_date, datetime.min.time(), tzinfo=self._moscow_tz) + end_msk = start_msk + timedelta(days=1) + caption = start_msk.strftime('%d.%m.%Y') + return ReportPeriodInfo( + start_msk=start_msk, + end_msk=end_msk, + title="Ежедневный отчет", + caption=caption, + range_caption=caption, + emoji="🗓️", + ) + + if period is ReportPeriod.WEEKLY: + end_msk = datetime.combine(now_msk.date(), datetime.min.time(), tzinfo=self._moscow_tz) + start_msk = end_msk - timedelta(days=7) + caption = ( + f"{start_msk.strftime('%d.%m.%Y')} — " + f"{(end_msk - timedelta(days=1)).strftime('%d.%m.%Y')}" + ) + return ReportPeriodInfo( + start_msk=start_msk, + end_msk=end_msk, + title="Еженедельный отчет", + caption=caption, + range_caption=caption, + emoji="🗓️", + ) + + if period is ReportPeriod.MONTHLY: + current_month_start = datetime(now_msk.year, now_msk.month, 1, tzinfo=self._moscow_tz) + end_msk = current_month_start + previous_month_last_day = current_month_start - timedelta(days=1) + start_msk = datetime( + previous_month_last_day.year, + previous_month_last_day.month, + 1, + tzinfo=self._moscow_tz, + ) + caption = ( + f"{start_msk.strftime('%d.%m.%Y')} — " + f"{previous_month_last_day.strftime('%d.%m.%Y')}" + ) + return ReportPeriodInfo( + start_msk=start_msk, + end_msk=end_msk, + title="Ежемесячный отчет", + caption=caption, + range_caption=caption, + emoji="📆", + ) + + logger.warning("Неизвестный период отчета: %s", period) + return None + + def _get_next_run_datetime(self) -> datetime: + dispatch_time = settings.get_admin_reports_time() + now_msk = datetime.now(self._moscow_tz) + + run_msk = datetime.combine(now_msk.date(), dispatch_time, tzinfo=self._moscow_tz) + if run_msk <= now_msk: + run_msk += timedelta(days=1) + + return run_msk.astimezone(self._utc_tz) + + +report_service = ReportService() diff --git a/locales/en.json b/locales/en.json index 40656662..3b108db8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -130,6 +130,7 @@ "ADMIN_MONITORING": "🔍 Monitoring", "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", "ADMIN_PROMOCODES": "🎫 Promo codes", + "ADMIN_REPORTS": "📈 Reports", "ADMIN_REFERRALS": "🤝 Referral program", "ADMIN_REMNAWAVE": "🖥️ Remnawave", "ADMIN_RULES": "📋 Rules", @@ -257,6 +258,12 @@ "ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.", "ADMIN_SUBSCRIPTIONS": "📱 Subscriptions", "ADMIN_USERS": "👥 Users", + "ADMIN_REPORTS_MENU_HINT": "Choose which report to send to the admin topic.", + "ADMIN_REPORTS_DAILY": "📅 Daily report (yesterday)", + "ADMIN_REPORTS_WEEKLY": "🗓️ Weekly report", + "ADMIN_REPORTS_MONTHLY": "📆 Monthly report", + "ADMIN_REPORTS_SENT": "✅ Report sent to the admin topic.", + "ADMIN_REPORTS_ERROR": "❌ Failed to send the report. Check notification settings.", "AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!", "AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically", "AUTOPAY_FAILED": "\n❌ Autopay failed\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n", diff --git a/locales/ru.json b/locales/ru.json index eb8d1ea4..a56fdd58 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -7,6 +7,7 @@ "ADMIN_MONITORING": "🔍 Мониторинг", "ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n", "ADMIN_PROMOCODES": "🎫 Промокоды", + "ADMIN_REPORTS": "📈 Отчеты", "ADMIN_REFERRALS": "🤝 Партнерка", "ADMIN_REMNAWAVE": "🖥️ Remnawave", "ADMIN_RULES": "📋 Правила", @@ -134,6 +135,12 @@ "ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.", "ADMIN_SUBSCRIPTIONS": "📱 Подписки", "ADMIN_USERS": "👥 Пользователи", + "ADMIN_REPORTS_MENU_HINT": "Выберите период отчета для отправки в админ-топик.", + "ADMIN_REPORTS_DAILY": "📅 Отчет за вчера", + "ADMIN_REPORTS_WEEKLY": "🗓️ Отчет за неделю", + "ADMIN_REPORTS_MONTHLY": "📆 Отчет за месяц", + "ADMIN_REPORTS_SENT": "✅ Отчет отправлен в админ-топик.", + "ADMIN_REPORTS_ERROR": "❌ Не удалось отправить отчет. Проверьте настройки уведомлений.", "AUTOPAY_BUTTON": "💳 Автоплатёж", "AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!", "AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически", diff --git a/main.py b/main.py index 4be13cde..17c3de48 100644 --- a/main.py +++ b/main.py @@ -20,6 +20,7 @@ from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookS from app.database.universal_migration import run_universal_migration from app.services.backup_service import backup_service from app.localization.loader import ensure_locale_templates +from app.services.report_service import report_service class GracefulExit: @@ -60,6 +61,7 @@ async def main(): monitoring_task = None maintenance_task = None version_check_task = None + reports_task = None polling_task = None try: @@ -96,6 +98,9 @@ async def main(): version_service.set_notification_service(admin_notification_service) logger.info(f"📄 Сервис версий настроен для репозитория: {version_service.repo}") logger.info(f"📦 Текущая версия: {version_service.current_version}") + + report_service.set_notification_service(admin_notification_service) + reports_task = await report_service.start() logger.info("🔗 Бот подключен к сервисам мониторинга и техработ") @@ -222,6 +227,13 @@ async def main(): if settings.is_version_check_enabled(): logger.info("🔄 Перезапуск сервиса проверки версий...") version_check_task = asyncio.create_task(version_service.start_periodic_check()) + + if reports_task and reports_task.done(): + exception = reports_task.exception() + if exception: + logger.error(f"Сервис отчетов завершился с ошибкой: {exception}") + new_task = await report_service.start() + reports_task = new_task if new_task else None if polling_task.done(): exception = polling_task.exception() @@ -277,6 +289,12 @@ async def main(): except asyncio.CancelledError: pass + logger.info("ℹ️ Остановка сервиса отчетов...") + try: + await report_service.stop() + except Exception as e: + logger.error(f"Ошибка остановки сервиса отчетов: {e}") + logger.info("ℹ️ Остановка сервиса бекапов...") try: await backup_service.stop_auto_backup() From 71588fda6332cd42d69f0b6071258d4f316bd569 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 07:25:44 +0300 Subject: [PATCH 027/146] Revert "Add automated admin reports" --- .env.example | 2 - README.md | 6 - app/bot.py | 2 - app/config.py | 23 -- app/handlers/admin/main.py | 25 +- app/handlers/admin/reports.py | 95 ------- app/keyboards/admin.py | 12 - app/services/admin_notification_service.py | 28 +- app/services/report_service.py | 284 --------------------- locales/en.json | 7 - locales/ru.json | 7 - main.py | 18 -- 12 files changed, 5 insertions(+), 504 deletions(-) delete mode 100644 app/handlers/admin/reports.py delete mode 100644 app/services/report_service.py diff --git a/.env.example b/.env.example index c78d8a82..2a801dfb 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,6 @@ ADMIN_NOTIFICATIONS_ENABLED=true ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ! ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов -ADMIN_REPORTS_TOPIC_ID=130 # Опционально: отдельный ID топика для отчетов -ADMIN_REPORTS_TIME_MOSCOW=09:00 # Время ежедневного отчета (МСК) # Обязательная подписка на канал CHANNEL_SUB_ID= # Опционально ID твоего канала (-100) CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал diff --git a/README.md b/README.md index 87da0ea8..7f65c08a 100644 --- a/README.md +++ b/README.md @@ -254,8 +254,6 @@ ADMIN_NOTIFICATIONS_ENABLED=true ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ! ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов -ADMIN_REPORTS_TOPIC_ID=130 # Опционально: отдельный ID топика для отчетов -ADMIN_REPORTS_TIME_MOSCOW=09:00 # Время ежедневного отчета (МСК) # Обязательная подписка на канал CHANNEL_SUB_ID= # Опционально ID твоего канала (-100) CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал @@ -971,12 +969,8 @@ docker compose down -v --remove-orphans ADMIN_NOTIFICATIONS_ENABLED=true ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # ID канала/группы ADMIN_NOTIFICATIONS_TOPIC_ID=123 # ID топика (опционально) -ADMIN_REPORTS_TOPIC_ID=130 # ID топика для отчетов (опционально) -ADMIN_REPORTS_TIME_MOSCOW=09:00 # Время ежедневного отчета (МСК) ``` -> ⚙️ Бот автоматически отправит ежедневный отчет за предыдущие сутки в указанное время (по МСК). Если `ADMIN_REPORTS_TOPIC_ID` не задан, отчеты будут приходить в основной топик уведомлений. В админ-панели доступен раздел «Отчеты» для ручной отправки ежедневных, недельных и месячных сводок. - #### 2. Создание канала 1. **Создайте приватный канал** или группу для уведомлений diff --git a/app/bot.py b/app/bot.py index a13e2237..c23bf06d 100644 --- a/app/bot.py +++ b/app/bot.py @@ -38,7 +38,6 @@ from app.handlers.admin import ( backup as admin_backup, welcome_text as admin_welcome_text, tickets as admin_tickets, - reports as admin_reports, ) from app.handlers.stars_payments import register_stars_handlers @@ -140,7 +139,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_backup.register_handlers(dp) admin_welcome_text.register_welcome_text_handlers(dp) admin_tickets.register_handlers(dp) - admin_reports.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/config.py b/app/config.py index 811c9999..8c8e6f17 100644 --- a/app/config.py +++ b/app/config.py @@ -2,7 +2,6 @@ import os import re import html from collections import defaultdict -from datetime import time as dt_time from typing import List, Optional, Union, Dict from pydantic_settings import BaseSettings from pydantic import field_validator, Field @@ -27,8 +26,6 @@ class Settings(BaseSettings): ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None - ADMIN_REPORTS_TOPIC_ID: Optional[int] = None - ADMIN_REPORTS_TIME_MOSCOW: str = "09:00" CHANNEL_SUB_ID: Optional[str] = None CHANNEL_LINK: Optional[str] = None @@ -640,26 +637,6 @@ class Settings(BaseSettings): return (self.ADMIN_NOTIFICATIONS_ENABLED and self.get_admin_notifications_chat_id() is not None) - def get_admin_reports_topic_id(self) -> Optional[int]: - if not self.ADMIN_REPORTS_TOPIC_ID: - return None - - try: - return int(self.ADMIN_REPORTS_TOPIC_ID) - except (ValueError, TypeError): - return None - - def get_admin_reports_time(self) -> dt_time: - raw_value = (self.ADMIN_REPORTS_TIME_MOSCOW or "09:00").strip() - - try: - hours_str, minutes_str = raw_value.split(":", maxsplit=1) - hours = max(0, min(23, int(hours_str))) - minutes = max(0, min(59, int(minutes_str))) - return dt_time(hour=hours, minute=minutes) - except (ValueError, AttributeError): - return dt_time(hour=9, minute=0) - def get_backup_send_chat_id(self) -> Optional[int]: if not self.BACKUP_SEND_CHAT_ID: return None diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py index ac86a658..a26fd7b3 100644 --- a/app/handlers/admin/main.py +++ b/app/handlers/admin/main.py @@ -12,8 +12,7 @@ from app.keyboards.admin import ( get_admin_communications_submenu_keyboard, get_admin_support_submenu_keyboard, get_admin_settings_submenu_keyboard, - get_admin_system_submenu_keyboard, - get_admin_reports_keyboard, + get_admin_system_submenu_keyboard ) from app.localization.texts import get_texts from app.handlers.admin import support_settings as support_settings_handlers @@ -145,23 +144,6 @@ async def show_support_submenu( await callback.answer() -@admin_required -@error_handler -async def show_reports_submenu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - texts = get_texts(db_user.language) - - await callback.message.edit_text( - f"📈 **{texts.ADMIN_REPORTS}**\n\n" + texts.ADMIN_REPORTS_MENU_HINT, - reply_markup=get_admin_reports_keyboard(db_user.language), - parse_mode="Markdown" - ) - await callback.answer() - - # Moderator panel entry (from main menu quick button) async def show_moderator_panel( callback: types.CallbackQuery, @@ -424,11 +406,6 @@ def register_handlers(dp: Dispatcher): show_support_audit, F.data.in_(["admin_support_audit"]) | F.data.startswith("admin_support_audit_page_") ) - - dp.callback_query.register( - show_reports_submenu, - F.data == "admin_submenu_reports" - ) dp.callback_query.register( show_settings_submenu, diff --git a/app/handlers/admin/reports.py b/app/handlers/admin/reports.py deleted file mode 100644 index d6f795e6..00000000 --- a/app/handlers/admin/reports.py +++ /dev/null @@ -1,95 +0,0 @@ -import logging - -from aiogram import Dispatcher, types, F -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.models import User -from app.localization.texts import get_texts -from app.services.report_service import report_service, ReportPeriod -from app.utils.decorators import admin_required, error_handler - - -logger = logging.getLogger(__name__) - - -async def _send_report( - callback: types.CallbackQuery, - db_user: User, - period: ReportPeriod, - success_message: str, - error_message: str, -): - success, _ = await report_service.send_report(period) - - if success: - logger.info("Админ %s отправил отчет %s", db_user.id, period.value) - await callback.answer(success_message) - else: - logger.error("Не удалось отправить отчет %s по запросу админа %s", period.value, db_user.id) - await callback.answer(error_message, show_alert=True) - - -@admin_required -@error_handler -async def send_daily_report( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - await _send_report( - callback, - db_user, - ReportPeriod.DAILY, - texts.ADMIN_REPORTS_SENT, - texts.ADMIN_REPORTS_ERROR, - ) - - -@admin_required -@error_handler -async def send_weekly_report( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - await _send_report( - callback, - db_user, - ReportPeriod.WEEKLY, - texts.ADMIN_REPORTS_SENT, - texts.ADMIN_REPORTS_ERROR, - ) - - -@admin_required -@error_handler -async def send_monthly_report( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - await _send_report( - callback, - db_user, - ReportPeriod.MONTHLY, - texts.ADMIN_REPORTS_SENT, - texts.ADMIN_REPORTS_ERROR, - ) - - -def register_handlers(dp: Dispatcher) -> None: - dp.callback_query.register( - send_daily_report, - F.data == "admin_report_daily", - ) - dp.callback_query.register( - send_weekly_report, - F.data == "admin_report_weekly", - ) - dp.callback_query.register( - send_monthly_report, - F.data == "admin_report_monthly", - ) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index e3c41abf..7b5f1549 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -10,7 +10,6 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")], [InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")], - [InlineKeyboardButton(text=texts.ADMIN_REPORTS, callback_data="admin_submenu_reports")], [InlineKeyboardButton(text="🛟 Поддержка", callback_data="admin_submenu_support")], [InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")], [InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")], @@ -92,17 +91,6 @@ def get_admin_support_submenu_keyboard(language: str = "ru") -> InlineKeyboardMa ]) -def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.ADMIN_REPORTS_DAILY, callback_data="admin_report_daily")], - [InlineKeyboardButton(text=texts.ADMIN_REPORTS_WEEKLY, callback_data="admin_report_weekly")], - [InlineKeyboardButton(text=texts.ADMIN_REPORTS_MONTHLY, callback_data="admin_report_monthly")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")] - ]) - - def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index f817c40a..1882e35b 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -20,7 +20,6 @@ class AdminNotificationService: self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None) self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None) self.ticket_topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID', None) - self.reports_topic_id = settings.get_admin_reports_topic_id() self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str: @@ -307,18 +306,11 @@ class AdminNotificationService: logger.error(f"Ошибка отправки уведомления о продлении: {e}") return False - async def _send_message( - self, - text: str, - reply_markup: types.InlineKeyboardMarkup | None = None, - *, - ticket_event: bool = False, - topic_id: Optional[int] = None - ) -> bool: + async def _send_message(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False) -> bool: if not self.chat_id: logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен") return False - + try: message_kwargs = { 'chat_id': self.chat_id, @@ -329,9 +321,7 @@ class AdminNotificationService: # route to ticket-specific topic if provided thread_id = None - if topic_id: - thread_id = topic_id - elif ticket_event and self.ticket_topic_id: + if ticket_event and self.ticket_topic_id: thread_id = self.ticket_topic_id elif self.topic_id: thread_id = self.topic_id @@ -339,7 +329,7 @@ class AdminNotificationService: message_kwargs['message_thread_id'] = thread_id if reply_markup is not None: message_kwargs['reply_markup'] = reply_markup - + await self.bot.send_message(**message_kwargs) logger.info(f"Уведомление отправлено в чат {self.chat_id}") return True @@ -356,16 +346,6 @@ class AdminNotificationService: def _is_enabled(self) -> bool: return self.enabled and bool(self.chat_id) - - def is_enabled(self) -> bool: - return self._is_enabled() - - async def send_report_message(self, text: str, *, topic_id: Optional[int] = None) -> bool: - if not self._is_enabled(): - return False - - effective_topic = topic_id or self.reports_topic_id or self.topic_id - return await self._send_message(text, topic_id=effective_topic) def _get_payment_method_display(self, payment_method: Optional[str]) -> str: method_names = { diff --git a/app/services/report_service.py b/app/services/report_service.py deleted file mode 100644 index 45735ff7..00000000 --- a/app/services/report_service.py +++ /dev/null @@ -1,284 +0,0 @@ -import asyncio -import logging -from dataclasses import dataclass -from datetime import datetime, timedelta -from enum import Enum -from typing import Optional, Tuple -from zoneinfo import ZoneInfo - -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings -from app.database.database import AsyncSessionLocal -from app.database.models import ( - Subscription, - SubscriptionStatus, - Transaction, - TransactionType, -) -from app.services.admin_notification_service import AdminNotificationService - - -logger = logging.getLogger(__name__) - - -class ReportPeriod(Enum): - DAILY = "daily" - WEEKLY = "weekly" - MONTHLY = "monthly" - - -@dataclass -class ReportPeriodInfo: - start_msk: datetime - end_msk: datetime - title: str - caption: str - range_caption: str - emoji: str - - -class ReportService: - def __init__(self) -> None: - self.notification_service: Optional[AdminNotificationService] = None - self._task: Optional[asyncio.Task] = None - self._stop_event = asyncio.Event() - self._moscow_tz = ZoneInfo("Europe/Moscow") - self._utc_tz = ZoneInfo("UTC") - - def set_notification_service(self, service: AdminNotificationService) -> None: - self.notification_service = service - - async def start(self) -> Optional[asyncio.Task]: - if self._task and not self._task.done(): - return self._task - - if not self.notification_service or not self.notification_service.is_enabled(): - logger.info("Сервис отчетов не запущен: админ-уведомления отключены или не настроен чат") - return None - - self._stop_event.clear() - self._task = asyncio.create_task(self._scheduler_loop()) - logger.info("Сервис отчетов запущен") - return self._task - - async def stop(self) -> None: - if not self._task: - return - - self._stop_event.set() - try: - await self._task - finally: - self._task = None - self._stop_event.clear() - logger.info("Сервис отчетов остановлен") - - async def send_report(self, period: ReportPeriod) -> Tuple[bool, str]: - text, _ = await self.generate_report(period) - - if not text: - return False, text - - if not self.notification_service or not self.notification_service.is_enabled(): - logger.warning("Отчет не отправлен: сервис админ-уведомлений недоступен") - return False, text - - success = await self.notification_service.send_report_message(text) - if success: - logger.info("Отчет %s отправлен", period.value) - else: - logger.error("Не удалось отправить отчет %s", period.value) - return success, text - - async def generate_report(self, period: ReportPeriod) -> Tuple[str, dict]: - info = self._get_period_info(period) - if not info: - return "", {} - - start_utc = info.start_msk.astimezone(self._utc_tz).replace(tzinfo=None) - end_utc = info.end_msk.astimezone(self._utc_tz).replace(tzinfo=None) - - async with AsyncSessionLocal() as session: - stats = await self._collect_stats(session, start_utc, end_utc) - - text = self._format_report(info, stats) - return text, stats - - async def _scheduler_loop(self) -> None: - while not self._stop_event.is_set(): - next_run = self._get_next_run_datetime() - now_utc = datetime.now(self._utc_tz) - wait_seconds = max(0, (next_run - now_utc).total_seconds()) - - try: - await asyncio.wait_for(self._stop_event.wait(), timeout=wait_seconds) - break - except asyncio.TimeoutError: - pass - - if self._stop_event.is_set(): - break - - try: - await self.send_report(ReportPeriod.DAILY) - except Exception as error: # pragma: no cover - defensive logging - logger.error("Ошибка отправки ежедневного отчета: %s", error, exc_info=True) - - async def _collect_stats(self, session: AsyncSession, start: datetime, end: datetime) -> dict: - now_utc = datetime.utcnow() - - total_trials_query = select(func.count()).select_from(Subscription).where( - Subscription.is_trial.is_(True), - Subscription.end_date > now_utc, - Subscription.status.in_([ - SubscriptionStatus.ACTIVE.value, - SubscriptionStatus.TRIAL.value, - ]), - ) - total_trials = (await session.scalar(total_trials_query)) or 0 - - total_paid_query = select(func.count()).select_from(Subscription).where( - Subscription.is_trial.is_(False), - Subscription.end_date > now_utc, - Subscription.status == SubscriptionStatus.ACTIVE.value, - ) - total_paid = (await session.scalar(total_paid_query)) or 0 - - new_trials_query = select(func.count()).select_from(Subscription).where( - Subscription.is_trial.is_(True), - Subscription.start_date >= start, - Subscription.start_date < end, - ) - new_trials = (await session.scalar(new_trials_query)) or 0 - - new_paid_query = select(func.count()).select_from(Subscription).where( - Subscription.is_trial.is_(False), - Subscription.start_date >= start, - Subscription.start_date < end, - ) - new_paid = (await session.scalar(new_paid_query)) or 0 - - payments_query = select( - func.count(Transaction.id), - func.coalesce(func.sum(Transaction.amount_kopeks), 0), - ).where( - Transaction.type == TransactionType.DEPOSIT.value, - Transaction.is_completed.is_(True), - Transaction.created_at >= start, - Transaction.created_at < end, - ) - payments_count, payments_sum = (await session.execute(payments_query)).one() - - return { - "total_trials": int(total_trials), - "total_paid": int(total_paid), - "new_trials": int(new_trials), - "new_paid": int(new_paid), - "payments_count": int(payments_count or 0), - "payments_sum": int(payments_sum or 0), - "period_start": start, - "period_end": end, - } - - def _format_report(self, info: ReportPeriodInfo, stats: dict) -> str: - now_msk = datetime.now(self._moscow_tz) - end_display = info.end_msk - timedelta(seconds=1) - period_range = ( - f"{info.start_msk.strftime('%d.%m.%Y %H:%M')} — " - f"{end_display.strftime('%d.%m.%Y %H:%M')}" - ) - - lines = [ - f"{info.emoji} {info.title} ({info.caption})", - "", - "🎯 Триалы", - f"• Активных сейчас: {stats['total_trials']}", - f"• Новых за период: {stats['new_trials']}", - "", - "💎 Платные подписки", - f"• Активных сейчас: {stats['total_paid']}", - f"• Новых за период: {stats['new_paid']}", - "", - "💳 Пополнения", - f"• Количество платежей: {stats['payments_count']}", - f"• Сумма: {settings.format_price(stats['payments_sum'])}", - "", - f"🕒 Период (МСК): {period_range}", - f"📅 Сформировано: {now_msk.strftime('%d.%m.%Y %H:%M')}", - ] - - return "\n".join(lines) - - def _get_period_info(self, period: ReportPeriod) -> Optional[ReportPeriodInfo]: - now_msk = datetime.now(self._moscow_tz) - - if period is ReportPeriod.DAILY: - target_date = now_msk.date() - timedelta(days=1) - start_msk = datetime.combine(target_date, datetime.min.time(), tzinfo=self._moscow_tz) - end_msk = start_msk + timedelta(days=1) - caption = start_msk.strftime('%d.%m.%Y') - return ReportPeriodInfo( - start_msk=start_msk, - end_msk=end_msk, - title="Ежедневный отчет", - caption=caption, - range_caption=caption, - emoji="🗓️", - ) - - if period is ReportPeriod.WEEKLY: - end_msk = datetime.combine(now_msk.date(), datetime.min.time(), tzinfo=self._moscow_tz) - start_msk = end_msk - timedelta(days=7) - caption = ( - f"{start_msk.strftime('%d.%m.%Y')} — " - f"{(end_msk - timedelta(days=1)).strftime('%d.%m.%Y')}" - ) - return ReportPeriodInfo( - start_msk=start_msk, - end_msk=end_msk, - title="Еженедельный отчет", - caption=caption, - range_caption=caption, - emoji="🗓️", - ) - - if period is ReportPeriod.MONTHLY: - current_month_start = datetime(now_msk.year, now_msk.month, 1, tzinfo=self._moscow_tz) - end_msk = current_month_start - previous_month_last_day = current_month_start - timedelta(days=1) - start_msk = datetime( - previous_month_last_day.year, - previous_month_last_day.month, - 1, - tzinfo=self._moscow_tz, - ) - caption = ( - f"{start_msk.strftime('%d.%m.%Y')} — " - f"{previous_month_last_day.strftime('%d.%m.%Y')}" - ) - return ReportPeriodInfo( - start_msk=start_msk, - end_msk=end_msk, - title="Ежемесячный отчет", - caption=caption, - range_caption=caption, - emoji="📆", - ) - - logger.warning("Неизвестный период отчета: %s", period) - return None - - def _get_next_run_datetime(self) -> datetime: - dispatch_time = settings.get_admin_reports_time() - now_msk = datetime.now(self._moscow_tz) - - run_msk = datetime.combine(now_msk.date(), dispatch_time, tzinfo=self._moscow_tz) - if run_msk <= now_msk: - run_msk += timedelta(days=1) - - return run_msk.astimezone(self._utc_tz) - - -report_service = ReportService() diff --git a/locales/en.json b/locales/en.json index 3b108db8..40656662 100644 --- a/locales/en.json +++ b/locales/en.json @@ -130,7 +130,6 @@ "ADMIN_MONITORING": "🔍 Monitoring", "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", "ADMIN_PROMOCODES": "🎫 Promo codes", - "ADMIN_REPORTS": "📈 Reports", "ADMIN_REFERRALS": "🤝 Referral program", "ADMIN_REMNAWAVE": "🖥️ Remnawave", "ADMIN_RULES": "📋 Rules", @@ -258,12 +257,6 @@ "ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.", "ADMIN_SUBSCRIPTIONS": "📱 Subscriptions", "ADMIN_USERS": "👥 Users", - "ADMIN_REPORTS_MENU_HINT": "Choose which report to send to the admin topic.", - "ADMIN_REPORTS_DAILY": "📅 Daily report (yesterday)", - "ADMIN_REPORTS_WEEKLY": "🗓️ Weekly report", - "ADMIN_REPORTS_MONTHLY": "📆 Monthly report", - "ADMIN_REPORTS_SENT": "✅ Report sent to the admin topic.", - "ADMIN_REPORTS_ERROR": "❌ Failed to send the report. Check notification settings.", "AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!", "AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically", "AUTOPAY_FAILED": "\n❌ Autopay failed\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n", diff --git a/locales/ru.json b/locales/ru.json index a56fdd58..eb8d1ea4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -7,7 +7,6 @@ "ADMIN_MONITORING": "🔍 Мониторинг", "ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n", "ADMIN_PROMOCODES": "🎫 Промокоды", - "ADMIN_REPORTS": "📈 Отчеты", "ADMIN_REFERRALS": "🤝 Партнерка", "ADMIN_REMNAWAVE": "🖥️ Remnawave", "ADMIN_RULES": "📋 Правила", @@ -135,12 +134,6 @@ "ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.", "ADMIN_SUBSCRIPTIONS": "📱 Подписки", "ADMIN_USERS": "👥 Пользователи", - "ADMIN_REPORTS_MENU_HINT": "Выберите период отчета для отправки в админ-топик.", - "ADMIN_REPORTS_DAILY": "📅 Отчет за вчера", - "ADMIN_REPORTS_WEEKLY": "🗓️ Отчет за неделю", - "ADMIN_REPORTS_MONTHLY": "📆 Отчет за месяц", - "ADMIN_REPORTS_SENT": "✅ Отчет отправлен в админ-топик.", - "ADMIN_REPORTS_ERROR": "❌ Не удалось отправить отчет. Проверьте настройки уведомлений.", "AUTOPAY_BUTTON": "💳 Автоплатёж", "AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!", "AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически", diff --git a/main.py b/main.py index 17c3de48..4be13cde 100644 --- a/main.py +++ b/main.py @@ -20,7 +20,6 @@ from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookS from app.database.universal_migration import run_universal_migration from app.services.backup_service import backup_service from app.localization.loader import ensure_locale_templates -from app.services.report_service import report_service class GracefulExit: @@ -61,7 +60,6 @@ async def main(): monitoring_task = None maintenance_task = None version_check_task = None - reports_task = None polling_task = None try: @@ -98,9 +96,6 @@ async def main(): version_service.set_notification_service(admin_notification_service) logger.info(f"📄 Сервис версий настроен для репозитория: {version_service.repo}") logger.info(f"📦 Текущая версия: {version_service.current_version}") - - report_service.set_notification_service(admin_notification_service) - reports_task = await report_service.start() logger.info("🔗 Бот подключен к сервисам мониторинга и техработ") @@ -227,13 +222,6 @@ async def main(): if settings.is_version_check_enabled(): logger.info("🔄 Перезапуск сервиса проверки версий...") version_check_task = asyncio.create_task(version_service.start_periodic_check()) - - if reports_task and reports_task.done(): - exception = reports_task.exception() - if exception: - logger.error(f"Сервис отчетов завершился с ошибкой: {exception}") - new_task = await report_service.start() - reports_task = new_task if new_task else None if polling_task.done(): exception = polling_task.exception() @@ -289,12 +277,6 @@ async def main(): except asyncio.CancelledError: pass - logger.info("ℹ️ Остановка сервиса отчетов...") - try: - await report_service.stop() - except Exception as e: - logger.error(f"Ошибка остановки сервиса отчетов: {e}") - logger.info("ℹ️ Остановка сервиса бекапов...") try: await backup_service.stop_auto_backup() From c1aa08b26643ad9a526605eb4529589125882e7a Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 07:26:18 +0300 Subject: [PATCH 028/146] Add scheduled admin reports and manual sending --- .env.example | 5 + app/bot.py | 2 + app/config.py | 33 +++ app/handlers/admin/reports.py | 85 ++++++++ app/keyboards/admin.py | 12 +- app/services/reporting_service.py | 351 ++++++++++++++++++++++++++++++ main.py | 20 +- 7 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 app/handlers/admin/reports.py create mode 100644 app/services/reporting_service.py diff --git a/.env.example b/.env.example index 2a801dfb..8412d058 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,11 @@ ADMIN_NOTIFICATIONS_ENABLED=true ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ! ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов +# Автоматические отчеты +ADMIN_REPORTS_ENABLED=false +ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID) +ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов +ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета # Обязательная подписка на канал CHANNEL_SUB_ID= # Опционально ID твоего канала (-100) CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал diff --git a/app/bot.py b/app/bot.py index c23bf06d..a13e2237 100644 --- a/app/bot.py +++ b/app/bot.py @@ -38,6 +38,7 @@ from app.handlers.admin import ( backup as admin_backup, welcome_text as admin_welcome_text, tickets as admin_tickets, + reports as admin_reports, ) from app.handlers.stars_payments import register_stars_handlers @@ -139,6 +140,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_backup.register_handlers(dp) admin_welcome_text.register_welcome_text_handlers(dp) admin_tickets.register_handlers(dp) + admin_reports.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/config.py b/app/config.py index 8c8e6f17..063106ce 100644 --- a/app/config.py +++ b/app/config.py @@ -1,7 +1,9 @@ +import logging import os import re import html from collections import defaultdict +from datetime import time from typing import List, Optional, Union, Dict from pydantic_settings import BaseSettings from pydantic import field_validator, Field @@ -27,6 +29,11 @@ class Settings(BaseSettings): ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None + ADMIN_REPORTS_ENABLED: bool = False + ADMIN_REPORTS_CHAT_ID: Optional[str] = None + ADMIN_REPORTS_TOPIC_ID: Optional[int] = None + ADMIN_REPORTS_SEND_TIME: Optional[str] = None + CHANNEL_SUB_ID: Optional[str] = None CHANNEL_LINK: Optional[str] = None CHANNEL_IS_REQUIRED_SUB: bool = False @@ -425,6 +432,32 @@ class Settings(BaseSettings): def format_price(self, price_kopeks: int) -> str: rubles = price_kopeks // 100 return f"{rubles} ₽" + + def get_reports_chat_id(self) -> Optional[str]: + if self.ADMIN_REPORTS_CHAT_ID: + return self.ADMIN_REPORTS_CHAT_ID + return self.ADMIN_NOTIFICATIONS_CHAT_ID + + def get_reports_topic_id(self) -> Optional[int]: + return self.ADMIN_REPORTS_TOPIC_ID or None + + def get_reports_send_time(self) -> Optional[time]: + value = self.ADMIN_REPORTS_SEND_TIME + if not value: + return None + + try: + hours_str, minutes_str = value.strip().split(":", 1) + hours = int(hours_str) + minutes = int(minutes_str) + if not (0 <= hours <= 23 and 0 <= minutes <= 59): + raise ValueError + return time(hour=hours, minute=minutes) + except (ValueError, AttributeError): + logging.getLogger(__name__).warning( + "Некорректное значение ADMIN_REPORTS_SEND_TIME: %s", value + ) + return None def kopeks_to_rubles(self, kopeks: int) -> float: return kopeks / 100 diff --git a/app/handlers/admin/reports.py b/app/handlers/admin/reports.py new file mode 100644 index 00000000..04075b38 --- /dev/null +++ b/app/handlers/admin/reports.py @@ -0,0 +1,85 @@ +import logging +from aiogram import Dispatcher, F, types +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User +from app.keyboards.admin import get_admin_reports_keyboard +from app.services.reporting_service import ( + ReportPeriod, + ReportingServiceError, + reporting_service, +) +from app.utils.decorators import admin_required, error_handler + + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def show_reports_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + await callback.message.edit_text( + "📊 Отчеты\n\n" + "Выберите период, чтобы отправить отчет в админский топик.", + reply_markup=get_admin_reports_keyboard(db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def send_daily_report( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + await _send_report(callback, ReportPeriod.DAILY) + + +@admin_required +@error_handler +async def send_weekly_report( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + await _send_report(callback, ReportPeriod.WEEKLY) + + +@admin_required +@error_handler +async def send_monthly_report( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + await _send_report(callback, ReportPeriod.MONTHLY) + + +async def _send_report(callback: types.CallbackQuery, period: ReportPeriod) -> None: + try: + report_text = await reporting_service.send_report(period, send_to_topic=True) + except ReportingServiceError as exc: + logger.warning("Не удалось отправить отчет: %s", exc) + await callback.answer(str(exc), show_alert=True) + return + except Exception as exc: # noqa: BLE001 + logger.error("Непредвиденная ошибка при отправке отчета: %s", exc) + await callback.answer("Не удалось отправить отчет. Попробуйте позже.", show_alert=True) + return + + await callback.message.answer(report_text) + await callback.answer("Отчет отправлен в топик") + + +def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register(show_reports_menu, F.data == "admin_reports") + dp.callback_query.register(send_daily_report, F.data == "admin_reports_daily") + dp.callback_query.register(send_weekly_report, F.data == "admin_reports_weekly") + dp.callback_query.register(send_monthly_report, F.data == "admin_reports_monthly") + diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 7b5f1549..0d4cf0d8 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -11,6 +11,7 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: [InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")], [InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")], [InlineKeyboardButton(text="🛟 Поддержка", callback_data="admin_submenu_support")], + [InlineKeyboardButton(text="📊 Отчеты", callback_data="admin_reports")], [InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")], [InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")], [InlineKeyboardButton(text="🛠️ Система", callback_data="admin_submenu_system")], @@ -111,7 +112,7 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text="📄 Обновления", callback_data="admin_updates"), @@ -123,6 +124,15 @@ def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMar ]) +def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📆 За вчера", callback_data="admin_reports_daily")], + [InlineKeyboardButton(text="🗓️ За неделю", callback_data="admin_reports_weekly")], + [InlineKeyboardButton(text="📅 За месяц", callback_data="admin_reports_monthly")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")] + ]) + + def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [ diff --git a/app/services/reporting_service.py b/app/services/reporting_service.py new file mode 100644 index 00000000..3f9161a1 --- /dev/null +++ b/app/services/reporting_service.py @@ -0,0 +1,351 @@ +import asyncio +import logging +from dataclasses import dataclass +from datetime import date, datetime, time as datetime_time, timedelta, timezone +from enum import Enum +from typing import Optional, Tuple + +from zoneinfo import ZoneInfo + +from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from sqlalchemy import func, select + +from app.config import settings +from app.database.crud.subscription import get_subscriptions_statistics +from app.database.database import AsyncSessionLocal +from app.database.models import ( + Subscription, + SubscriptionConversion, + Transaction, + TransactionType, +) + + +logger = logging.getLogger(__name__) + + +class ReportingServiceError(RuntimeError): + """Base error for the reporting service.""" + + +class ReportPeriod(Enum): + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY = "monthly" + + +@dataclass(slots=True) +class ReportPeriodRange: + start_msk: datetime + end_msk: datetime + label: str + + +class ReportingService: + """Generates admin summary reports and can schedule daily delivery.""" + + def __init__(self) -> None: + self.bot: Optional[Bot] = None + self._task: Optional[asyncio.Task] = None + self._moscow_tz = ZoneInfo("Europe/Moscow") + + def set_bot(self, bot: Bot) -> None: + self.bot = bot + + def is_running(self) -> bool: + return self._task is not None and not self._task.done() + + async def start(self) -> None: + await self.stop() + + if not settings.ADMIN_REPORTS_ENABLED: + logger.info("Сервис отчетов отключен настройками") + return + + if not self.bot: + logger.warning("Невозможно запустить сервис отчетов без экземпляра бота") + return + + chat_id = settings.get_reports_chat_id() + if not chat_id: + logger.warning("Сервис отчетов не запущен: не указан чат для отправки отчетов") + return + + send_time = settings.get_reports_send_time() + if not send_time: + logger.warning("Сервис отчетов не запущен: не указано время ежедневной отправки") + return + + self._task = asyncio.create_task(self._auto_daily_loop(send_time)) + logger.info( + "📊 Сервис отчетов запущен: ежедневная отправка в %s по МСК", + send_time.strftime("%H:%M"), + ) + + async def stop(self) -> None: + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def send_report( + self, + period: ReportPeriod, + *, + report_date: Optional[date] = None, + send_to_topic: bool = False, + ) -> str: + report_text = await self._build_report(period, report_date) + + if send_to_topic: + await self._deliver_report(report_text) + + return report_text + + async def _auto_daily_loop(self, send_time: datetime_time) -> None: + try: + next_run_utc, report_date = self._calculate_next_run(send_time) + + while True: + now_utc = datetime.now(timezone.utc) + delay = (next_run_utc - now_utc).total_seconds() + + if delay > 0: + await asyncio.sleep(delay) + + try: + await self.send_report( + ReportPeriod.DAILY, + report_date=report_date, + send_to_topic=True, + ) + logger.info( + "📊 Автоматический отчет за %s отправлен", + report_date.strftime("%d.%m.%Y"), + ) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + logger.error("Ошибка автоматической отправки отчета: %s", exc) + + next_run_utc, report_date = self._calculate_next_run(send_time) + + except asyncio.CancelledError: + logger.info("Сервис отчетов остановлен") + raise + except Exception as exc: # noqa: BLE001 + logger.error("Критическая ошибка в сервисе отчетов: %s", exc) + + def _calculate_next_run( + self, + send_time: datetime_time, + ) -> Tuple[datetime, date]: + now_msk = datetime.now(self._moscow_tz) + candidate = datetime.combine(now_msk.date(), send_time, tzinfo=self._moscow_tz) + + if now_msk >= candidate: + candidate += timedelta(days=1) + + report_date = (candidate - timedelta(days=1)).date() + return candidate.astimezone(timezone.utc), report_date + + async def _deliver_report(self, report_text: str) -> None: + if not self.bot: + raise ReportingServiceError("Бот не инициализирован для отправки отчета") + + chat_id = settings.get_reports_chat_id() + if not chat_id: + raise ReportingServiceError("Не задан чат для отправки отчета") + + topic_id = settings.get_reports_topic_id() + + try: + await self.bot.send_message( + chat_id=chat_id, + text=report_text, + message_thread_id=topic_id, + ) + except (TelegramBadRequest, TelegramForbiddenError) as exc: + logger.error("Не удалось отправить отчет: %s", exc) + raise ReportingServiceError("Не удалось отправить отчет в чат") from exc + + async def _build_report( + self, + period: ReportPeriod, + report_date: Optional[date], + ) -> str: + period_range = self._get_period_range(period, report_date) + start_utc = period_range.start_msk.astimezone(timezone.utc).replace(tzinfo=None) + end_utc = period_range.end_msk.astimezone(timezone.utc).replace(tzinfo=None) + + async with AsyncSessionLocal() as session: + totals = await self._collect_current_totals(session) + period_stats = await self._collect_period_stats(session, start_utc, end_utc) + + header = ( + f"📊 Отчет за {period_range.label}" + if period == ReportPeriod.DAILY + else f"📊 Отчет за период {period_range.label}" + ) + + lines = [ + header, + "", + "🎯 Триалы", + f"• Активных сейчас: {totals['active_trials']}", + f"• Новых за период: {period_stats['new_trials']}", + "", + "💎 Платные подписки", + f"• Активных сейчас: {totals['active_paid']}", + f"• Новых за период: {period_stats['new_paid_subscriptions']}", + "", + "💰 Платежи", + f"• Оплат подписок: {period_stats['subscription_payments_count']} на сумму " + f"{self._format_amount(period_stats['subscription_payments_amount'])}", + f"• Пополнений: {period_stats['deposits_count']} на сумму " + f"{self._format_amount(period_stats['deposits_amount'])}", + f"• Всего поступлений: {period_stats['total_payments_count']} на сумму " + f"{self._format_amount(period_stats['total_payments_amount'])}", + ] + + return "\n".join(lines) + + def _get_period_range( + self, + period: ReportPeriod, + report_date: Optional[date], + ) -> ReportPeriodRange: + now_msk = datetime.now(self._moscow_tz) + + if period == ReportPeriod.DAILY: + target_date = report_date or (now_msk.date() - timedelta(days=1)) + start = datetime.combine(target_date, datetime_time.min, tzinfo=self._moscow_tz) + end = start + timedelta(days=1) + elif period == ReportPeriod.WEEKLY: + end_date = report_date or now_msk.date() + start_date = end_date - timedelta(days=7) + start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz) + end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz) + elif period == ReportPeriod.MONTHLY: + end_date = report_date or now_msk.date() + start_date = end_date - timedelta(days=30) + start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz) + end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz) + else: # pragma: no cover - defensive branch + raise ReportingServiceError(f"Неизвестный период отчета: {period}") + + label = self._format_period_label(start, end) + return ReportPeriodRange(start, end, label) + + async def _collect_current_totals(self, session) -> dict: + stats = await get_subscriptions_statistics(session) + return { + "active_trials": stats.get("trial_subscriptions", 0) or 0, + "active_paid": stats.get("paid_subscriptions", 0) or 0, + } + + async def _collect_period_stats( + self, + session, + start_utc: datetime, + end_utc: datetime, + ) -> dict: + new_trials_result = await session.execute( + select(func.count(Subscription.id)).where( + Subscription.created_at >= start_utc, + Subscription.created_at < end_utc, + Subscription.is_trial == True, # noqa: E712 + ) + ) + new_trials = int(new_trials_result.scalar() or 0) + + direct_paid_result = await session.execute( + select(func.count(Subscription.id)).where( + Subscription.created_at >= start_utc, + Subscription.created_at < end_utc, + Subscription.is_trial == False, # noqa: E712 + ) + ) + direct_paid = int(direct_paid_result.scalar() or 0) + + conversions_result = await session.execute( + select(func.count(SubscriptionConversion.id)).where( + SubscriptionConversion.converted_at >= start_utc, + SubscriptionConversion.converted_at < end_utc, + ) + ) + conversions_count = int(conversions_result.scalar() or 0) + + subscription_payments_row = ( + await session.execute( + select( + func.count(Transaction.id), + func.coalesce(func.sum(Transaction.amount_kopeks), 0), + ).where( + Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, + Transaction.is_completed == True, # noqa: E712 + Transaction.created_at >= start_utc, + Transaction.created_at < end_utc, + ) + ) + ).one() + + deposits_row = ( + await session.execute( + select( + func.count(Transaction.id), + func.coalesce(func.sum(Transaction.amount_kopeks), 0), + ).where( + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed == True, # noqa: E712 + Transaction.created_at >= start_utc, + Transaction.created_at < end_utc, + ) + ) + ).one() + + subscription_payments_count = int(subscription_payments_row[0] or 0) + subscription_payments_amount = int(subscription_payments_row[1] or 0) + deposits_count = int(deposits_row[0] or 0) + deposits_amount = int(deposits_row[1] or 0) + + total_payments_count = subscription_payments_count + deposits_count + total_payments_amount = subscription_payments_amount + deposits_amount + + return { + "new_trials": new_trials, + "new_paid_subscriptions": direct_paid + conversions_count, + "subscription_payments_count": subscription_payments_count, + "subscription_payments_amount": subscription_payments_amount, + "deposits_count": deposits_count, + "deposits_amount": deposits_amount, + "total_payments_count": total_payments_count, + "total_payments_amount": total_payments_amount, + } + + def _format_period_label(self, start: datetime, end: datetime) -> str: + start_date = start.astimezone(self._moscow_tz).date() + end_boundary = (end - timedelta(seconds=1)).astimezone(self._moscow_tz) + end_date = end_boundary.date() + + if start_date == end_date: + return start_date.strftime("%d.%m.%Y") + + return ( + f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}" + ) + + def _format_amount(self, amount_kopeks: int) -> str: + if not amount_kopeks: + return "0 ₽" + + rubles = amount_kopeks / 100 + return f"{rubles:,.2f} ₽".replace(",", " ") + + +reporting_service = ReportingService() + diff --git a/main.py b/main.py index 4be13cde..254e5c5d 100644 --- a/main.py +++ b/main.py @@ -19,6 +19,7 @@ from app.external.yookassa_webhook import start_yookassa_webhook_server from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookServer from app.database.universal_migration import run_universal_migration from app.services.backup_service import backup_service +from app.services.reporting_service import reporting_service from app.localization.loader import ensure_locale_templates @@ -111,7 +112,14 @@ async def main(): logger.info("✅ Сервис бекапов инициализирован") except Exception as e: logger.error(f"❌ Ошибка инициализации сервиса бекапов: {e}") - + + logger.info("📊 Инициализация сервиса отчетов...") + try: + reporting_service.set_bot(bot) + await reporting_service.start() + except Exception as e: + logger.error(f"❌ Ошибка запуска сервиса отчетов: {e}") + payment_service = PaymentService(bot) webhook_needed = ( @@ -188,6 +196,10 @@ async def main(): logger.info(f" Мониторинг: {'Включен' if monitoring_task else 'Отключен'}") logger.info(f" Техработы: {'Включен' if maintenance_task else 'Отключен'}") logger.info(f" Проверка версий: {'Включен' if version_check_task else 'Отключен'}") + logger.info( + " Отчеты: %s", + "Включен" if reporting_service.is_running() else "Отключен", + ) logger.info("=" * 50) try: @@ -277,6 +289,12 @@ async def main(): except asyncio.CancelledError: pass + logger.info("ℹ️ Остановка сервиса отчетов...") + try: + await reporting_service.stop() + except Exception as e: + logger.error(f"Ошибка остановки сервиса отчетов: {e}") + logger.info("ℹ️ Остановка сервиса бекапов...") try: await backup_service.stop_auto_backup() From 66ec241c4826aff1fd927bb2543dffbb4b17a8c4 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 07:37:21 +0300 Subject: [PATCH 029/146] Add close action for admin reports and move menu entry --- app/handlers/admin/main.py | 2 +- app/handlers/admin/reports.py | 44 ++++++++++++++++++++++++++++++----- app/keyboards/admin.py | 10 +++++++- locales/en.json | 4 ++++ locales/ru.json | 4 ++++ 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py index a26fd7b3..5a7b6669 100644 --- a/app/handlers/admin/main.py +++ b/app/handlers/admin/main.py @@ -255,7 +255,7 @@ async def show_system_submenu( await callback.message.edit_text( "🛠️ **Системные функции**\n\n" - "Обновления, резервные копии и системные операции:", + "Отчеты, обновления, резервные копии и системные операции:", reply_markup=get_admin_system_submenu_keyboard(db_user.language), parse_mode="Markdown" ) diff --git a/app/handlers/admin/reports.py b/app/handlers/admin/reports.py index 04075b38..44049276 100644 --- a/app/handlers/admin/reports.py +++ b/app/handlers/admin/reports.py @@ -1,9 +1,14 @@ import logging from aiogram import Dispatcher, F, types +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User -from app.keyboards.admin import get_admin_reports_keyboard +from app.keyboards.admin import ( + get_admin_report_result_keyboard, + get_admin_reports_keyboard, +) +from app.localization.texts import get_texts from app.services.reporting_service import ( ReportPeriod, ReportingServiceError, @@ -38,7 +43,7 @@ async def send_daily_report( db_user: User, db: AsyncSession, ) -> None: - await _send_report(callback, ReportPeriod.DAILY) + await _send_report(callback, ReportPeriod.DAILY, db_user.language) @admin_required @@ -48,7 +53,7 @@ async def send_weekly_report( db_user: User, db: AsyncSession, ) -> None: - await _send_report(callback, ReportPeriod.WEEKLY) + await _send_report(callback, ReportPeriod.WEEKLY, db_user.language) @admin_required @@ -58,10 +63,14 @@ async def send_monthly_report( db_user: User, db: AsyncSession, ) -> None: - await _send_report(callback, ReportPeriod.MONTHLY) + await _send_report(callback, ReportPeriod.MONTHLY, db_user.language) -async def _send_report(callback: types.CallbackQuery, period: ReportPeriod) -> None: +async def _send_report( + callback: types.CallbackQuery, + period: ReportPeriod, + language: str, +) -> None: try: report_text = await reporting_service.send_report(period, send_to_topic=True) except ReportingServiceError as exc: @@ -73,13 +82,36 @@ async def _send_report(callback: types.CallbackQuery, period: ReportPeriod) -> N await callback.answer("Не удалось отправить отчет. Попробуйте позже.", show_alert=True) return - await callback.message.answer(report_text) + await callback.message.answer( + report_text, + reply_markup=get_admin_report_result_keyboard(language), + ) await callback.answer("Отчет отправлен в топик") +@admin_required +@error_handler +async def close_report_message( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + texts = get_texts(db_user.language) + + try: + await callback.message.delete() + except (TelegramBadRequest, TelegramForbiddenError) as exc: + logger.warning("Не удалось закрыть сообщение отчета: %s", exc) + await callback.answer(texts.t("REPORT_CLOSE_ERROR", "Не удалось закрыть отчет."), show_alert=True) + return + + await callback.answer(texts.t("REPORT_CLOSED", "Отчет закрыт.")) + + def register_handlers(dp: Dispatcher) -> None: dp.callback_query.register(show_reports_menu, F.data == "admin_reports") dp.callback_query.register(send_daily_report, F.data == "admin_reports_daily") dp.callback_query.register(send_weekly_report, F.data == "admin_reports_weekly") dp.callback_query.register(send_monthly_report, F.data == "admin_reports_monthly") + dp.callback_query.register(close_report_message, F.data == "admin_close_report") diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 0d4cf0d8..8219147b 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -11,7 +11,6 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: [InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")], [InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")], [InlineKeyboardButton(text="🛟 Поддержка", callback_data="admin_submenu_support")], - [InlineKeyboardButton(text="📊 Отчеты", callback_data="admin_reports")], [InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")], [InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")], [InlineKeyboardButton(text="🛠️ Система", callback_data="admin_submenu_system")], @@ -118,6 +117,7 @@ def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMar InlineKeyboardButton(text="📄 Обновления", callback_data="admin_updates"), InlineKeyboardButton(text="🗄️ Бекапы", callback_data="backup_panel") ], + [InlineKeyboardButton(text=texts.t("ADMIN_REPORTS", "📊 Отчеты"), callback_data="admin_reports")], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") ] @@ -133,6 +133,14 @@ def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) +def get_admin_report_result_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("REPORT_CLOSE", "❌ Закрыть"), callback_data="admin_close_report")] + ]) + + def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [ diff --git a/locales/en.json b/locales/en.json index 40656662..1b416564 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2,6 +2,7 @@ "ADD_COUNTRIES_BUTTON": "🌐 Add countries", "ADMIN_MAIN_MENU": "🏠 Main menu", "ADMIN_CAMPAIGNS": "📣 Promotional campaigns", + "ADMIN_REPORTS": "📊 Reports", "AUTOPAY_BUTTON": "💳 Auto payment", "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days", "BACK": "⬅️ Back", @@ -190,6 +191,9 @@ "MARK_AS_ANSWERED": "✅ Mark as answered", "TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:", "CLOSE_NOTIFICATION": "❌ Close notification", + "REPORT_CLOSE": "❌ Close", + "REPORT_CLOSED": "✅ Report closed.", + "REPORT_CLOSE_ERROR": "❌ Failed to close the report.", "NOTIFICATION_CLOSED": "Notification closed.", "UNBLOCK": "✅ Unblock", "BLOCK_FOREVER": "🚫 Block permanently", diff --git a/locales/ru.json b/locales/ru.json index eb8d1ea4..736d38e1 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -5,6 +5,7 @@ "ADMIN_CAMPAIGNS": "📣 Рекламные кампании", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MONITORING": "🔍 Мониторинг", + "ADMIN_REPORTS": "📊 Отчеты", "ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n", "ADMIN_PROMOCODES": "🎫 Промокоды", "ADMIN_REFERRALS": "🤝 Партнерка", @@ -67,6 +68,9 @@ "MARK_AS_ANSWERED": "✅ Отметить как отвеченный", "TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:", "CLOSE_NOTIFICATION": "❌ Закрыть уведомление", + "REPORT_CLOSE": "❌ Закрыть", + "REPORT_CLOSED": "✅ Отчет закрыт.", + "REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.", "NOTIFICATION_CLOSED": "Уведомление закрыто.", "UNBLOCK": "✅ Разблокировать", "BLOCK_FOREVER": "🚫 Заблокировать", From b352cb27012f23132694f37af072f02ff5702378 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:05:50 +0300 Subject: [PATCH 030/146] Add automated inactivity and post-expiration notifications --- app/database/models.py | 4 +- app/database/universal_migration.py | 38 ++ app/handlers/admin/monitoring.py | 229 ++++++++++- app/keyboards/admin.py | 62 ++- app/services/monitoring_service.py | 385 +++++++++++++++++- app/services/notification_settings_service.py | 216 ++++++++++ app/services/subscription_service.py | 9 +- app/states.py | 4 + 8 files changed, 934 insertions(+), 13 deletions(-) create mode 100644 app/services/notification_settings_service.py diff --git a/app/database/models.py b/app/database/models.py index f9b6d8ab..16cb4cb4 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -418,7 +418,9 @@ class Subscription(Base): created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - + + first_usage_at = Column(DateTime, nullable=True) + remnawave_short_uuid = Column(String(255), nullable=True) user = relationship("User", back_populates="subscription") diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 40273ff4..0bec1a8f 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1189,6 +1189,34 @@ async def add_ticket_sla_columns(): logger.error(f"Ошибка добавления SLA колонки в tickets: {e}") return False + +async def add_subscription_first_usage_column() -> bool: + try: + column_exists = await check_column_exists('subscriptions', 'first_usage_at') + if column_exists: + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + if db_type == 'sqlite': + alter_sql = "ALTER TABLE subscriptions ADD COLUMN first_usage_at DATETIME" + elif db_type == 'postgresql': + alter_sql = "ALTER TABLE subscriptions ADD COLUMN first_usage_at TIMESTAMP NULL" + elif db_type == 'mysql': + alter_sql = "ALTER TABLE subscriptions ADD COLUMN first_usage_at DATETIME NULL" + else: + logger.error(f"Неподдерживаемый тип БД для добавления first_usage_at: {db_type}") + return False + + await conn.execute(text(alter_sql)) + logger.info("✅ Добавлена колонка subscriptions.first_usage_at") + return True + + except Exception as e: + logger.error(f"Ошибка добавления first_usage_at в subscriptions: {e}") + return False + + async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: @@ -1502,6 +1530,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением полей SLA в tickets") + logger.info("=== ДОБАВЛЕНИЕ ПОЛЯ FIRST_USAGE_AT В SUBSCRIPTIONS ===") + first_usage_added = await add_subscription_first_usage_column() + if first_usage_added: + logger.info("✅ Поле first_usage_at в subscriptions готово") + else: + logger.warning("⚠️ Проблемы с добавлением поля first_usage_at в subscriptions") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===") try: async with engine.begin() as conn: @@ -1651,6 +1686,7 @@ async def check_migration_status(): "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, "users_auto_promo_group_assigned_column": False, + "subscriptions_first_usage_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1666,6 +1702,7 @@ async def check_migration_status(): status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') + status["subscriptions_first_usage_column"] = await check_column_exists('subscriptions', 'first_usage_at') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -1701,6 +1738,7 @@ async def check_migration_status(): "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", + "subscriptions_first_usage_column": "Колонка first_usage_at в subscriptions", } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index be876876..0f7585fc 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -1,22 +1,75 @@ import asyncio import logging from datetime import datetime, timedelta +from typing import Callable from aiogram import Router, F from aiogram.types import Message, CallbackQuery from aiogram.filters import Command +from aiogram.fsm.context import FSMContext from app.config import settings from app.database.database import get_db from app.services.monitoring_service import monitoring_service from app.utils.decorators import admin_required from app.utils.pagination import paginate_list -from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard +from app.keyboards.admin import ( + get_monitoring_keyboard, + get_admin_main_keyboard, + get_monitoring_notification_settings_keyboard, +) from app.localization.texts import get_texts +from app.services.notification_settings_service import NotificationSettingsService +from app.states import NotificationSettingsStates logger = logging.getLogger(__name__) router = Router() +def _format_notification_settings_text(settings_data: dict) -> str: + def status(flag: bool) -> str: + return "🟢 Вкл" if flag else "🔴 Выкл" + + return ( + "🔔 Настройки автоматических уведомлений\n\n" + "🧪 Тестовый период:\n" + f"• 1 час без подключения: {status(settings_data.get('trial_inactive_1h_enabled'))}\n" + f"• 24 часа без подключения: {status(settings_data.get('trial_inactive_24h_enabled'))}\n\n" + "📅 После окончания подписки:\n" + f"• 1 день после истечения: {status(settings_data.get('expired_day1_enabled'))}\n" + f"• 2-3 дня: {status(settings_data.get('expired_day23_enabled'))}" + f" — скидка {settings_data.get('expired_day23_discount_percent', 0)}%" + f" на {settings_data.get('expired_day23_valid_hours', 0)} ч.\n" + f"• N дней (от {settings_data.get('expired_dayn_threshold_days', 0)}):" + f" {status(settings_data.get('expired_dayn_enabled'))}" + f" — скидка {settings_data.get('expired_dayn_discount_percent', 0)}%" + f" на {settings_data.get('expired_dayn_valid_hours', 0)} ч.\n\n" + "Нажмите на кнопки ниже для переключения или изменения параметров." + ) + + +def _get_notification_settings_view() -> tuple[str, 'InlineKeyboardMarkup']: + settings_data = NotificationSettingsService.get_all() + return ( + _format_notification_settings_text(settings_data), + get_monitoring_notification_settings_keyboard(settings_data), + ) + + +async def _toggle_notification_setting( + callback: CallbackQuery, + getter: Callable[[], bool], + setter: Callable[[bool], bool], + label: str, +) -> None: + new_value = not getter() + if setter(new_value): + await callback.answer(f"{label}: {'включено' if new_value else 'отключено'}") + text, keyboard = _get_notification_settings_view() + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + else: + await callback.answer("❌ Не удалось сохранить настройку", show_alert=True) + + @router.callback_query(F.data == "admin_monitoring") @admin_required async def admin_monitoring_menu(callback: CallbackQuery): @@ -200,6 +253,17 @@ async def clear_logs_callback(callback: CallbackQuery): await callback.answer(f"❌ Ошибка очистки: {str(e)}", show_alert=True) +@router.callback_query(F.data == "admin_mon_toggle_notifications") +@admin_required +async def monitoring_notifications_menu(callback: CallbackQuery): + try: + text, keyboard = _get_notification_settings_view() + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + except Exception as e: + logger.error(f"Ошибка отображения настроек уведомлений: {e}") + await callback.answer("❌ Не удалось получить настройки", show_alert=True) + + @router.callback_query(F.data == "admin_mon_test_notifications") @admin_required async def test_notifications_callback(callback: CallbackQuery): @@ -230,6 +294,128 @@ async def test_notifications_callback(callback: CallbackQuery): await callback.answer(f"❌ Ошибка отправки: {str(e)}", show_alert=True) +@router.callback_query(F.data == "admin_mon_toggle_notif_trial1h") +@admin_required +async def toggle_trial_1h_notification(callback: CallbackQuery): + await _toggle_notification_setting( + callback, + NotificationSettingsService.is_trial_inactive_1h_enabled, + NotificationSettingsService.set_trial_inactive_1h_enabled, + "Триал 1ч", + ) + + +@router.callback_query(F.data == "admin_mon_toggle_notif_trial24h") +@admin_required +async def toggle_trial_24h_notification(callback: CallbackQuery): + await _toggle_notification_setting( + callback, + NotificationSettingsService.is_trial_inactive_24h_enabled, + NotificationSettingsService.set_trial_inactive_24h_enabled, + "Триал 24ч", + ) + + +@router.callback_query(F.data == "admin_mon_toggle_notif_expired_day1") +@admin_required +async def toggle_expired_day1_notification(callback: CallbackQuery): + await _toggle_notification_setting( + callback, + NotificationSettingsService.is_expired_day1_enabled, + NotificationSettingsService.set_expired_day1_enabled, + "Истекла 1 день", + ) + + +@router.callback_query(F.data == "admin_mon_toggle_notif_expired_day23") +@admin_required +async def toggle_expired_day23_notification(callback: CallbackQuery): + await _toggle_notification_setting( + callback, + NotificationSettingsService.is_expired_day23_enabled, + NotificationSettingsService.set_expired_day23_enabled, + "Истекла 2-3 дня", + ) + + +@router.callback_query(F.data == "admin_mon_toggle_notif_expired_dayn") +@admin_required +async def toggle_expired_dayn_notification(callback: CallbackQuery): + await _toggle_notification_setting( + callback, + NotificationSettingsService.is_expired_dayn_enabled, + NotificationSettingsService.set_expired_dayn_enabled, + "Истекла N дней", + ) + + +async def _start_waiting_for_value( + callback: CallbackQuery, + state: FSMContext, + param: str, + prompt: str, +) -> None: + await state.set_state(NotificationSettingsStates.waiting_for_value) + await state.update_data(param=param) + await callback.message.answer(prompt) + await callback.answer() + + +@router.callback_query(F.data == "admin_mon_edit_notif_day23_discount") +@admin_required +async def edit_day23_discount(callback: CallbackQuery, state: FSMContext): + await _start_waiting_for_value( + callback, + state, + "day23_discount", + "Введите новую скидку для уведомления на 2-3 день (%).", + ) + + +@router.callback_query(F.data == "admin_mon_edit_notif_day23_valid") +@admin_required +async def edit_day23_valid(callback: CallbackQuery, state: FSMContext): + await _start_waiting_for_value( + callback, + state, + "day23_valid", + "Введите срок действия предложения для 2-3 дня (в часах).", + ) + + +@router.callback_query(F.data == "admin_mon_edit_notif_dayn_discount") +@admin_required +async def edit_dayn_discount(callback: CallbackQuery, state: FSMContext): + await _start_waiting_for_value( + callback, + state, + "dayn_discount", + "Введите новую скидку для уведомления после N дней (%).", + ) + + +@router.callback_query(F.data == "admin_mon_edit_notif_dayn_valid") +@admin_required +async def edit_dayn_valid(callback: CallbackQuery, state: FSMContext): + await _start_waiting_for_value( + callback, + state, + "dayn_valid", + "Введите срок действия предложения после N дней (в часах).", + ) + + +@router.callback_query(F.data == "admin_mon_edit_notif_dayn_threshold") +@admin_required +async def edit_dayn_threshold(callback: CallbackQuery, state: FSMContext): + await _start_waiting_for_value( + callback, + state, + "dayn_threshold", + "Введите через сколько дней после окончания отправлять усиленную скидку (минимум 4).", + ) + + @router.callback_query(F.data == "admin_mon_statistics") @admin_required async def monitoring_statistics_callback(callback: CallbackQuery): @@ -286,6 +472,47 @@ async def monitoring_statistics_callback(callback: CallbackQuery): await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True) +@router.message(NotificationSettingsStates.waiting_for_value) +@admin_required +async def notification_setting_value(message: Message, state: FSMContext): + data = await state.get_data() + param = data.get("param") + value_raw = (message.text or "").strip() + + try: + value_int = int(value_raw) + except ValueError: + await message.answer("❌ Введите целое число.") + return + + if param == "day23_discount": + success = NotificationSettingsService.set_expired_day23_discount_percent(value_int) + result_text = "Скидка для уведомлений на 2-3 день обновлена." + elif param == "day23_valid": + success = NotificationSettingsService.set_expired_day23_valid_hours(value_int) + result_text = "Срок действия предложения на 2-3 день обновлён." + elif param == "dayn_discount": + success = NotificationSettingsService.set_expired_dayn_discount_percent(value_int) + result_text = "Скидка для уведомлений после N дней обновлена." + elif param == "dayn_valid": + success = NotificationSettingsService.set_expired_dayn_valid_hours(value_int) + result_text = "Срок действия предложения после N дней обновлён." + elif param == "dayn_threshold": + success = NotificationSettingsService.set_expired_dayn_threshold_days(value_int) + result_text = "Порог дней для усиленной скидки обновлён." + else: + success = False + result_text = "Неизвестный параметр." + + if success: + await message.answer(f"✅ {result_text}") + text, keyboard = _get_notification_settings_view() + await message.answer(text, parse_mode="HTML", reply_markup=keyboard) + await state.clear() + else: + await message.answer("❌ Не удалось сохранить значение. Попробуйте ещё раз.") + + def get_monitoring_logs_keyboard(current_page: int, total_pages: int): from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 8219147b..b21d0d67 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -920,7 +920,8 @@ def get_monitoring_status_keyboard( keyboard.append(info_row) test_row = [ - InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications") + InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"), + InlineKeyboardButton(text="🔔 Настройки уведомлений", callback_data="admin_mon_toggle_notifications"), ] keyboard.append(test_row) @@ -946,6 +947,65 @@ def get_monitoring_settings_keyboard() -> InlineKeyboardMarkup: ]) +def get_monitoring_notification_settings_keyboard(settings_data: dict) -> InlineKeyboardMarkup: + def _status(enabled: bool) -> str: + return "🟢" if enabled else "🔴" + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text=f"{_status(settings_data.get('trial_inactive_1h_enabled'))} Триал · 1 час", + callback_data="admin_mon_toggle_notif_trial1h", + ), + InlineKeyboardButton( + text=f"{_status(settings_data.get('trial_inactive_24h_enabled'))} Триал · 24 часа", + callback_data="admin_mon_toggle_notif_trial24h", + ), + ], + [ + InlineKeyboardButton( + text=f"{_status(settings_data.get('expired_day1_enabled'))} Истекла · 1 день", + callback_data="admin_mon_toggle_notif_expired_day1", + ), + ], + [ + InlineKeyboardButton( + text=f"{_status(settings_data.get('expired_day23_enabled'))} Истекла · 2-3 дня", + callback_data="admin_mon_toggle_notif_expired_day23", + ), + InlineKeyboardButton( + text=f"✏️ Скидка {settings_data.get('expired_day23_discount_percent', 0)}%", + callback_data="admin_mon_edit_notif_day23_discount", + ), + InlineKeyboardButton( + text=f"⏳ {settings_data.get('expired_day23_valid_hours', 0)} ч", + callback_data="admin_mon_edit_notif_day23_valid", + ), + ], + [ + InlineKeyboardButton( + text=f"{_status(settings_data.get('expired_dayn_enabled'))} Истекла · N дней", + callback_data="admin_mon_toggle_notif_expired_dayn", + ), + InlineKeyboardButton( + text=f"✏️ Скидка {settings_data.get('expired_dayn_discount_percent', 0)}%", + callback_data="admin_mon_edit_notif_dayn_discount", + ), + InlineKeyboardButton( + text=f"⏳ {settings_data.get('expired_dayn_valid_hours', 0)} ч", + callback_data="admin_mon_edit_notif_dayn_valid", + ), + InlineKeyboardButton( + text=f"📅 от {settings_data.get('expired_dayn_threshold_days', 0)} дн.", + callback_data="admin_mon_edit_notif_dayn_threshold", + ), + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring"), + ], + ]) + + def get_log_type_filter_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [ diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index a190aec4..f2a96272 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -24,6 +24,7 @@ from app.database.crud.notification import ( from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus from app.services.subscription_service import SubscriptionService from app.services.payment_service import PaymentService +from app.services.notification_settings_service import NotificationSettingsService from app.localization.texts import get_texts from app.external.remnawave_api import ( @@ -80,10 +81,12 @@ class MonitoringService: async for db in get_db(): try: await self._cleanup_notification_cache() - + await self._check_expired_subscriptions(db) + await self._check_expired_followups(db) await self._check_expiring_subscriptions(db) - await self._check_trial_expiring_soon(db) + await self._check_trial_expiring_soon(db) + await self._check_trial_inactive_users(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) await self._sync_with_remnawave(db) @@ -117,7 +120,7 @@ class MonitoringService: async def _check_expired_subscriptions(self, db: AsyncSession): try: expired_subscriptions = await get_expired_subscriptions(db) - + for subscription in expired_subscriptions: from app.database.crud.subscription import expire_subscription await expire_subscription(db, subscription) @@ -141,6 +144,119 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки истёкших подписок: {e}") + async def _check_expired_followups(self, db: AsyncSession): + try: + if not self.bot: + return + + day1_enabled = NotificationSettingsService.is_expired_day1_enabled() + day23_enabled = NotificationSettingsService.is_expired_day23_enabled() + dayn_enabled = NotificationSettingsService.is_expired_dayn_enabled() + + if not any([day1_enabled, day23_enabled, dayn_enabled]): + return + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where(Subscription.status == SubscriptionStatus.EXPIRED.value) + ) + expired_subscriptions = result.scalars().all() + + if not expired_subscriptions: + return + + now = datetime.utcnow() + sent_day1 = 0 + sent_day23 = 0 + sent_dayn = 0 + threshold_n = NotificationSettingsService.get_expired_dayn_threshold_days() + discount_day23 = NotificationSettingsService.get_expired_day23_discount_percent() + discount_dayn = NotificationSettingsService.get_expired_dayn_discount_percent() + valid_day23 = NotificationSettingsService.get_expired_day23_valid_hours() + valid_dayn = NotificationSettingsService.get_expired_dayn_valid_hours() + + for subscription in expired_subscriptions: + user = subscription.user + if not user: + continue + + delta = now - subscription.end_date + if delta.total_seconds() < 0: + continue + + days_since = int(delta.total_seconds() // 86400) + + if days_since < 1: + continue + + if ( + day1_enabled + and days_since == 1 + and not await notification_sent(db, user.id, subscription.id, "expired_followup_day1", days_since) + ): + success = await self._send_expired_followup_notification( + user, + subscription, + "day1", + days_since=days_since, + ) + if success: + await record_notification(db, user.id, subscription.id, "expired_followup_day1", days_since) + sent_day1 += 1 + + if ( + day23_enabled + and days_since in {2, 3} + and not await notification_sent(db, user.id, subscription.id, "expired_followup_day23", days_since) + ): + success = await self._send_expired_followup_notification( + user, + subscription, + "day23", + days_since=days_since, + discount_percent=discount_day23, + valid_hours=valid_day23, + ) + if success: + await record_notification(db, user.id, subscription.id, "expired_followup_day23", days_since) + sent_day23 += 1 + + if ( + dayn_enabled + and days_since >= threshold_n + and not await notification_sent(db, user.id, subscription.id, "expired_followup_dayn", days_since) + ): + success = await self._send_expired_followup_notification( + user, + subscription, + "dayn", + days_since=days_since, + discount_percent=discount_dayn, + valid_hours=valid_dayn, + threshold_days=threshold_n, + ) + if success: + await record_notification(db, user.id, subscription.id, "expired_followup_dayn", days_since) + sent_dayn += 1 + + total_sent = sent_day1 + sent_day23 + sent_dayn + if total_sent > 0: + await self._log_monitoring_event( + db, + "expired_followup_notifications", + "Отправлены напоминания после окончания подписки", + { + "sent_day1": sent_day1, + "sent_day23": sent_day23, + "sent_dayn": sent_dayn, + "total": total_sent, + }, + ) + + except Exception as e: + logger.error(f"Ошибка отправки последующих уведомлений по истекшим подпискам: {e}") + async def update_remnawave_user( self, db: AsyncSession, @@ -250,7 +366,7 @@ class MonitoringService: async def _check_trial_expiring_soon(self, db: AsyncSession): try: threshold_time = datetime.utcnow() + timedelta(hours=2) - + result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -288,7 +404,99 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}") - + + async def _check_trial_inactive_users(self, db: AsyncSession): + try: + if not self.bot: + return + + one_hour_enabled = NotificationSettingsService.is_trial_inactive_1h_enabled() + day_enabled = NotificationSettingsService.is_trial_inactive_24h_enabled() + + if not (one_hour_enabled or day_enabled): + return + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + Subscription.is_trial == True, + Subscription.first_usage_at.is_(None), + Subscription.start_date.is_not(None), + Subscription.status.in_( + [ + SubscriptionStatus.ACTIVE.value, + SubscriptionStatus.TRIAL.value, + ] + ), + ) + ) + trial_subscriptions = result.scalars().all() + + if not trial_subscriptions: + return + + now = datetime.utcnow() + sent_1h = 0 + sent_24h = 0 + + for subscription in trial_subscriptions: + user = subscription.user + if not user: + continue + + try: + sync_success = await self.subscription_service.sync_subscription_usage(db, subscription) + if sync_success: + await db.refresh(subscription) + except Exception as sync_error: # pragma: no cover - defensive log + logger.debug( + "Не удалось синхронизировать использование триальной подписки %s: %s", + subscription.id, + sync_error, + ) + + if subscription.first_usage_at: + continue + + started_at = subscription.start_date or subscription.created_at + if not started_at: + continue + + time_since_start = now - started_at + + if ( + one_hour_enabled + and time_since_start >= timedelta(hours=1) + and not await notification_sent(db, user.id, subscription.id, "trial_inactive_1h", 0) + ): + success = await self._send_trial_inactive_notification(user, subscription, "1h") + if success: + await record_notification(db, user.id, subscription.id, "trial_inactive_1h", 0) + sent_1h += 1 + + if ( + day_enabled + and time_since_start >= timedelta(days=1) + and not await notification_sent(db, user.id, subscription.id, "trial_inactive_24h", 1) + ): + success = await self._send_trial_inactive_notification(user, subscription, "24h") + if success: + await record_notification(db, user.id, subscription.id, "trial_inactive_24h", 1) + sent_24h += 1 + + total_sent = sent_1h + sent_24h + if total_sent > 0: + await self._log_monitoring_event( + db, + "trial_inactive_notifications", + "Отправлены напоминания о неиспользуемом триале", + {"sent_1h": sent_1h, "sent_24h": sent_24h, "total": total_sent}, + ) + + except Exception as e: + logger.error(f"Ошибка проверки неиспользуемых триалов: {e}") + async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]: current_time = datetime.utcnow() threshold_date = current_time + timedelta(days=days_before) @@ -465,7 +673,7 @@ class MonitoringService: async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool: try: texts = get_texts(user.language) - + message = f""" 🎁 Тестовая подписка скоро закончится! @@ -501,7 +709,170 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}") return False - + + async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, stage: str) -> bool: + try: + language = (user.language or settings.DEFAULT_LANGUAGE).lower() + support_url = settings.get_support_contact_url() + support_text = settings.get_support_contact_display() + + if stage == "1h": + if language.startswith("en"): + message = ( + "👋 Let's set up your VPN\n\n" + "It's been an hour since you activated the trial, but we haven't seen any connections yet.\n\n" + "Tap the button below to add the configuration and start browsing safely." + ) + else: + message = ( + "👋 Давайте подключим VPN\n\n" + "Прошел час после активации тестового доступа, но подключений пока нет.\n\n" + "Нажмите кнопку ниже, чтобы добавить конфигурацию и начать пользоваться сервисом." + ) + else: # 24h stage + if language.startswith("en"): + message = ( + "⏰ Trial is still waiting for you\n\n" + "A whole day has passed and the VPN is still not connected.\n\n" + "Connect now and make the most of the test period — it only takes a minute!" + ) + else: + message = ( + "⏰ Тест все еще не используется\n\n" + "Прошли сутки, но VPN так и не был подключен.\n\n" + "Подключитесь сейчас и успейте воспользоваться тестовым периодом — это занимает меньше минуты!" + ) + + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + buttons = [ + [InlineKeyboardButton(text="🔗 Подключить VPN" if not language.startswith("en") else "🔗 Connect VPN", callback_data="subscription_connect")], + [InlineKeyboardButton(text="📱 Моя подписка" if not language.startswith("en") else "📱 My subscription", callback_data="menu_subscription")], + ] + + if support_url: + buttons.append( + [ + InlineKeyboardButton( + text=("🛟 Поддержка" if not language.startswith("en") else "🛟 Support"), + url=support_url, + ) + ] + ) + elif support_text: + message += f"\n\n💬 {support_text}" + + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error(f"Ошибка отправки уведомления о неиспользуемом триале пользователю {user.telegram_id}: {e}") + return False + + async def _send_expired_followup_notification( + self, + user: User, + subscription: Subscription, + stage: str, + *, + days_since: int, + discount_percent: int | None = None, + valid_hours: int | None = None, + threshold_days: int | None = None, + ) -> bool: + try: + language = (user.language or settings.DEFAULT_LANGUAGE).lower() + support_url = settings.get_support_contact_url() + support_text = settings.get_support_contact_display() + + if language.startswith("en"): + if stage == "day1": + message = ( + "📅 Your VPN subscription expired yesterday\n\n" + "Renew now to restore unlimited access.\n" + "Tap a button below — activation is instant." + ) + elif stage == "day23": + message = ( + "🔥 Special return offer\n\n" + f"It's been {days_since} days since the subscription expired.\n" + f"Renew now with a {discount_percent}% discount valid for {valid_hours} hours." + ) + else: + trigger_days = threshold_days or days_since + message = ( + "🎁 Extra discount just for you\n\n" + f"The subscription ended {days_since} days ago.\n" + f"Come back with a {discount_percent}% discount valid for {valid_hours} hours.\n" + f"Offer unlocked after {trigger_days} days without renewal." + ) + extend_text = "⏰ Renew subscription" + buy_text = "💎 Buy new period" + balance_text = "💳 Top up balance" + support_button_text = "🛟 Support" + else: + if stage == "day1": + message = ( + "📅 Подписка истекла вчера\n\n" + "Продлите доступ прямо сейчас — активация моментальная." + ) + elif stage == "day23": + message = ( + "🔥 Скидка на продление\n\n" + f"Подписка закончилась {days_since} дня назад.\n" + f"Вернитесь со скидкой {discount_percent}% — предложение действует {valid_hours} ч." + ) + else: + trigger_days = threshold_days or days_since + message = ( + "🎁 Дополнительная скидка для возврата\n\n" + f"Прошло {days_since} дней без подписки.\n" + f"Продлите её со скидкой {discount_percent}% в течение {valid_hours} ч.\n" + f"Предложение доступно после {trigger_days} дней без продления." + ) + extend_text = "⏰ Продлить подписку" + buy_text = "💎 Купить новый период" + balance_text = "💳 Пополнить баланс" + support_button_text = "🛟 Поддержка" + + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + buttons = [ + [InlineKeyboardButton(text=extend_text, callback_data="subscription_extend")], + [InlineKeyboardButton(text=buy_text, callback_data="menu_buy")], + [InlineKeyboardButton(text=balance_text, callback_data="balance_topup")], + ] + + if support_url: + buttons.append([ + InlineKeyboardButton(text=support_button_text, url=support_url) + ]) + elif support_text: + message += f"\n\n💬 {support_text}" + + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error( + f"Ошибка отправки уведомления о завершившейся подписке пользователю {user.telegram_id}: {e}" + ) + return False + async def _send_autopay_success_notification(self, user: User, amount: int, days: int): try: texts = get_texts(user.language) diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py new file mode 100644 index 00000000..99b27e3e --- /dev/null +++ b/app/services/notification_settings_service.py @@ -0,0 +1,216 @@ +"""Runtime storage for user notification preferences.""" +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any, Dict + +logger = logging.getLogger(__name__) + + +class NotificationSettingsService: + """Manage runtime-configurable notification settings. + + Values are stored in ``data/notification_settings.json`` and can be + modified from the admin panel without restarting the bot. + """ + + _storage_path: Path = Path("data/notification_settings.json") + _data: Dict[str, Any] = {} + _loaded: bool = False + + _defaults: Dict[str, Any] = { + "trial_inactive_1h_enabled": True, + "trial_inactive_24h_enabled": True, + "expired_day1_enabled": True, + "expired_day23_enabled": True, + "expired_day23_discount_percent": 20, + "expired_day23_valid_hours": 24, + "expired_dayn_enabled": True, + "expired_dayn_discount_percent": 30, + "expired_dayn_valid_hours": 24, + "expired_dayn_threshold_days": 5, + } + + @classmethod + def _ensure_storage_dir(cls) -> None: + try: + cls._storage_path.parent.mkdir(parents=True, exist_ok=True) + except Exception as exc: # pragma: no cover - defensive logging + logger.error("Failed to create notification settings directory: %s", exc) + + @classmethod + def _load(cls) -> None: + if cls._loaded: + return + + cls._ensure_storage_dir() + if cls._storage_path.exists(): + try: + cls._data = json.loads(cls._storage_path.read_text(encoding="utf-8")) + except Exception as exc: + logger.error("Failed to load notification settings: %s", exc) + cls._data = {} + else: + cls._data = {} + + cls._loaded = True + + @classmethod + def _save(cls) -> bool: + cls._ensure_storage_dir() + try: + cls._storage_path.write_text( + json.dumps(cls._data, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return True + except Exception as exc: # pragma: no cover - defensive logging + logger.error("Failed to save notification settings: %s", exc) + return False + + # Helper accessors ----------------------------------------------------- + @classmethod + def _get_bool(cls, key: str) -> bool: + cls._load() + if key in cls._data: + return bool(cls._data[key]) + return bool(cls._defaults.get(key, False)) + + @classmethod + def _set_bool(cls, key: str, value: bool) -> bool: + cls._load() + cls._data[key] = bool(value) + return cls._save() + + @classmethod + def _get_int(cls, key: str) -> int: + cls._load() + if key in cls._data: + try: + return int(cls._data[key]) + except (TypeError, ValueError): + pass + return int(cls._defaults.get(key, 0)) + + @classmethod + def _set_int(cls, key: str, value: int) -> bool: + cls._load() + cls._data[key] = int(value) + return cls._save() + + @classmethod + def get_all(cls) -> Dict[str, Any]: + cls._load() + data = {**cls._defaults, **cls._data} + # cast ints to ensure consistent types + int_keys = [ + "expired_day23_discount_percent", + "expired_day23_valid_hours", + "expired_dayn_discount_percent", + "expired_dayn_valid_hours", + "expired_dayn_threshold_days", + ] + for key in int_keys: + try: + data[key] = int(data[key]) + except (TypeError, ValueError): + data[key] = int(cls._defaults[key]) + bool_keys = [ + "trial_inactive_1h_enabled", + "trial_inactive_24h_enabled", + "expired_day1_enabled", + "expired_day23_enabled", + "expired_dayn_enabled", + ] + for key in bool_keys: + data[key] = bool(data.get(key, cls._defaults[key])) + return data + + # Trial inactivity ----------------------------------------------------- + @classmethod + def is_trial_inactive_1h_enabled(cls) -> bool: + return cls._get_bool("trial_inactive_1h_enabled") + + @classmethod + def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool: + return cls._set_bool("trial_inactive_1h_enabled", enabled) + + @classmethod + def is_trial_inactive_24h_enabled(cls) -> bool: + return cls._get_bool("trial_inactive_24h_enabled") + + @classmethod + def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool: + return cls._set_bool("trial_inactive_24h_enabled", enabled) + + # Expired subscription follow-ups ------------------------------------- + @classmethod + def is_expired_day1_enabled(cls) -> bool: + return cls._get_bool("expired_day1_enabled") + + @classmethod + def set_expired_day1_enabled(cls, enabled: bool) -> bool: + return cls._set_bool("expired_day1_enabled", enabled) + + @classmethod + def is_expired_day23_enabled(cls) -> bool: + return cls._get_bool("expired_day23_enabled") + + @classmethod + def set_expired_day23_enabled(cls, enabled: bool) -> bool: + return cls._set_bool("expired_day23_enabled", enabled) + + @classmethod + def get_expired_day23_discount_percent(cls) -> int: + return max(0, min(100, cls._get_int("expired_day23_discount_percent"))) + + @classmethod + def set_expired_day23_discount_percent(cls, percent: int) -> bool: + percent = max(0, min(100, int(percent))) + return cls._set_int("expired_day23_discount_percent", percent) + + @classmethod + def get_expired_day23_valid_hours(cls) -> int: + return max(1, cls._get_int("expired_day23_valid_hours")) + + @classmethod + def set_expired_day23_valid_hours(cls, hours: int) -> bool: + hours = max(1, int(hours)) + return cls._set_int("expired_day23_valid_hours", hours) + + @classmethod + def is_expired_dayn_enabled(cls) -> bool: + return cls._get_bool("expired_dayn_enabled") + + @classmethod + def set_expired_dayn_enabled(cls, enabled: bool) -> bool: + return cls._set_bool("expired_dayn_enabled", enabled) + + @classmethod + def get_expired_dayn_discount_percent(cls) -> int: + return max(0, min(100, cls._get_int("expired_dayn_discount_percent"))) + + @classmethod + def set_expired_dayn_discount_percent(cls, percent: int) -> bool: + percent = max(0, min(100, int(percent))) + return cls._set_int("expired_dayn_discount_percent", percent) + + @classmethod + def get_expired_dayn_valid_hours(cls) -> int: + return max(1, cls._get_int("expired_dayn_valid_hours")) + + @classmethod + def set_expired_dayn_valid_hours(cls, hours: int) -> bool: + hours = max(1, int(hours)) + return cls._set_int("expired_dayn_valid_hours", hours) + + @classmethod + def get_expired_dayn_threshold_days(cls) -> int: + return max(4, cls._get_int("expired_dayn_threshold_days")) + + @classmethod + def set_expired_dayn_threshold_days(cls, days: int) -> bool: + days = max(4, int(days)) + return cls._set_int("expired_dayn_threshold_days", days) diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index e21e259c..a3a6fc07 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -268,12 +268,15 @@ class SubscriptionService: remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid) if not remnawave_user: return False - + used_gb = self._bytes_to_gb(remnawave_user.used_traffic_bytes) subscription.traffic_used_gb = used_gb - + + if used_gb > 0 and not subscription.first_usage_at: + subscription.first_usage_at = datetime.utcnow() + await db.commit() - + logger.debug(f"Синхронизирован трафик для подписки {subscription.id}: {used_gb} ГБ") return True diff --git a/app/states.py b/app/states.py index 45e87e21..3887dfc7 100644 --- a/app/states.py +++ b/app/states.py @@ -121,6 +121,10 @@ class AdminTicketStates(StatesGroup): class SupportSettingsStates(StatesGroup): waiting_for_desc = State() + +class NotificationSettingsStates(StatesGroup): + waiting_for_value = State() + class AutoPayStates(StatesGroup): setting_autopay_days = State() confirming_autopay_toggle = State() From 1e436189b7f51727e1c9c9110c9e6e344dd013d6 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:10:11 +0300 Subject: [PATCH 031/146] Revert "Add automated inactivity and post-expiration notifications" --- app/database/models.py | 4 +- app/database/universal_migration.py | 38 -- app/handlers/admin/monitoring.py | 229 +---------- app/keyboards/admin.py | 62 +-- app/services/monitoring_service.py | 385 +----------------- app/services/notification_settings_service.py | 216 ---------- app/services/subscription_service.py | 9 +- app/states.py | 4 - 8 files changed, 13 insertions(+), 934 deletions(-) delete mode 100644 app/services/notification_settings_service.py diff --git a/app/database/models.py b/app/database/models.py index 16cb4cb4..f9b6d8ab 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -418,9 +418,7 @@ class Subscription(Base): created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - first_usage_at = Column(DateTime, nullable=True) - + remnawave_short_uuid = Column(String(255), nullable=True) user = relationship("User", back_populates="subscription") diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 0bec1a8f..40273ff4 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1189,34 +1189,6 @@ async def add_ticket_sla_columns(): logger.error(f"Ошибка добавления SLA колонки в tickets: {e}") return False - -async def add_subscription_first_usage_column() -> bool: - try: - column_exists = await check_column_exists('subscriptions', 'first_usage_at') - if column_exists: - return True - - async with engine.begin() as conn: - db_type = await get_database_type() - if db_type == 'sqlite': - alter_sql = "ALTER TABLE subscriptions ADD COLUMN first_usage_at DATETIME" - elif db_type == 'postgresql': - alter_sql = "ALTER TABLE subscriptions ADD COLUMN first_usage_at TIMESTAMP NULL" - elif db_type == 'mysql': - alter_sql = "ALTER TABLE subscriptions ADD COLUMN first_usage_at DATETIME NULL" - else: - logger.error(f"Неподдерживаемый тип БД для добавления first_usage_at: {db_type}") - return False - - await conn.execute(text(alter_sql)) - logger.info("✅ Добавлена колонка subscriptions.first_usage_at") - return True - - except Exception as e: - logger.error(f"Ошибка добавления first_usage_at в subscriptions: {e}") - return False - - async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: @@ -1530,13 +1502,6 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением полей SLA в tickets") - logger.info("=== ДОБАВЛЕНИЕ ПОЛЯ FIRST_USAGE_AT В SUBSCRIPTIONS ===") - first_usage_added = await add_subscription_first_usage_column() - if first_usage_added: - logger.info("✅ Поле first_usage_at в subscriptions готово") - else: - logger.warning("⚠️ Проблемы с добавлением поля first_usage_at в subscriptions") - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===") try: async with engine.begin() as conn: @@ -1686,7 +1651,6 @@ async def check_migration_status(): "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, "users_auto_promo_group_assigned_column": False, - "subscriptions_first_usage_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1702,7 +1666,6 @@ async def check_migration_status(): status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') - status["subscriptions_first_usage_column"] = await check_column_exists('subscriptions', 'first_usage_at') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -1738,7 +1701,6 @@ async def check_migration_status(): "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", - "subscriptions_first_usage_column": "Колонка first_usage_at в subscriptions", } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 0f7585fc..be876876 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -1,75 +1,22 @@ import asyncio import logging from datetime import datetime, timedelta -from typing import Callable from aiogram import Router, F from aiogram.types import Message, CallbackQuery from aiogram.filters import Command -from aiogram.fsm.context import FSMContext from app.config import settings from app.database.database import get_db from app.services.monitoring_service import monitoring_service from app.utils.decorators import admin_required from app.utils.pagination import paginate_list -from app.keyboards.admin import ( - get_monitoring_keyboard, - get_admin_main_keyboard, - get_monitoring_notification_settings_keyboard, -) +from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard from app.localization.texts import get_texts -from app.services.notification_settings_service import NotificationSettingsService -from app.states import NotificationSettingsStates logger = logging.getLogger(__name__) router = Router() -def _format_notification_settings_text(settings_data: dict) -> str: - def status(flag: bool) -> str: - return "🟢 Вкл" if flag else "🔴 Выкл" - - return ( - "🔔 Настройки автоматических уведомлений\n\n" - "🧪 Тестовый период:\n" - f"• 1 час без подключения: {status(settings_data.get('trial_inactive_1h_enabled'))}\n" - f"• 24 часа без подключения: {status(settings_data.get('trial_inactive_24h_enabled'))}\n\n" - "📅 После окончания подписки:\n" - f"• 1 день после истечения: {status(settings_data.get('expired_day1_enabled'))}\n" - f"• 2-3 дня: {status(settings_data.get('expired_day23_enabled'))}" - f" — скидка {settings_data.get('expired_day23_discount_percent', 0)}%" - f" на {settings_data.get('expired_day23_valid_hours', 0)} ч.\n" - f"• N дней (от {settings_data.get('expired_dayn_threshold_days', 0)}):" - f" {status(settings_data.get('expired_dayn_enabled'))}" - f" — скидка {settings_data.get('expired_dayn_discount_percent', 0)}%" - f" на {settings_data.get('expired_dayn_valid_hours', 0)} ч.\n\n" - "Нажмите на кнопки ниже для переключения или изменения параметров." - ) - - -def _get_notification_settings_view() -> tuple[str, 'InlineKeyboardMarkup']: - settings_data = NotificationSettingsService.get_all() - return ( - _format_notification_settings_text(settings_data), - get_monitoring_notification_settings_keyboard(settings_data), - ) - - -async def _toggle_notification_setting( - callback: CallbackQuery, - getter: Callable[[], bool], - setter: Callable[[bool], bool], - label: str, -) -> None: - new_value = not getter() - if setter(new_value): - await callback.answer(f"{label}: {'включено' if new_value else 'отключено'}") - text, keyboard = _get_notification_settings_view() - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) - else: - await callback.answer("❌ Не удалось сохранить настройку", show_alert=True) - - @router.callback_query(F.data == "admin_monitoring") @admin_required async def admin_monitoring_menu(callback: CallbackQuery): @@ -253,17 +200,6 @@ async def clear_logs_callback(callback: CallbackQuery): await callback.answer(f"❌ Ошибка очистки: {str(e)}", show_alert=True) -@router.callback_query(F.data == "admin_mon_toggle_notifications") -@admin_required -async def monitoring_notifications_menu(callback: CallbackQuery): - try: - text, keyboard = _get_notification_settings_view() - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) - except Exception as e: - logger.error(f"Ошибка отображения настроек уведомлений: {e}") - await callback.answer("❌ Не удалось получить настройки", show_alert=True) - - @router.callback_query(F.data == "admin_mon_test_notifications") @admin_required async def test_notifications_callback(callback: CallbackQuery): @@ -294,128 +230,6 @@ async def test_notifications_callback(callback: CallbackQuery): await callback.answer(f"❌ Ошибка отправки: {str(e)}", show_alert=True) -@router.callback_query(F.data == "admin_mon_toggle_notif_trial1h") -@admin_required -async def toggle_trial_1h_notification(callback: CallbackQuery): - await _toggle_notification_setting( - callback, - NotificationSettingsService.is_trial_inactive_1h_enabled, - NotificationSettingsService.set_trial_inactive_1h_enabled, - "Триал 1ч", - ) - - -@router.callback_query(F.data == "admin_mon_toggle_notif_trial24h") -@admin_required -async def toggle_trial_24h_notification(callback: CallbackQuery): - await _toggle_notification_setting( - callback, - NotificationSettingsService.is_trial_inactive_24h_enabled, - NotificationSettingsService.set_trial_inactive_24h_enabled, - "Триал 24ч", - ) - - -@router.callback_query(F.data == "admin_mon_toggle_notif_expired_day1") -@admin_required -async def toggle_expired_day1_notification(callback: CallbackQuery): - await _toggle_notification_setting( - callback, - NotificationSettingsService.is_expired_day1_enabled, - NotificationSettingsService.set_expired_day1_enabled, - "Истекла 1 день", - ) - - -@router.callback_query(F.data == "admin_mon_toggle_notif_expired_day23") -@admin_required -async def toggle_expired_day23_notification(callback: CallbackQuery): - await _toggle_notification_setting( - callback, - NotificationSettingsService.is_expired_day23_enabled, - NotificationSettingsService.set_expired_day23_enabled, - "Истекла 2-3 дня", - ) - - -@router.callback_query(F.data == "admin_mon_toggle_notif_expired_dayn") -@admin_required -async def toggle_expired_dayn_notification(callback: CallbackQuery): - await _toggle_notification_setting( - callback, - NotificationSettingsService.is_expired_dayn_enabled, - NotificationSettingsService.set_expired_dayn_enabled, - "Истекла N дней", - ) - - -async def _start_waiting_for_value( - callback: CallbackQuery, - state: FSMContext, - param: str, - prompt: str, -) -> None: - await state.set_state(NotificationSettingsStates.waiting_for_value) - await state.update_data(param=param) - await callback.message.answer(prompt) - await callback.answer() - - -@router.callback_query(F.data == "admin_mon_edit_notif_day23_discount") -@admin_required -async def edit_day23_discount(callback: CallbackQuery, state: FSMContext): - await _start_waiting_for_value( - callback, - state, - "day23_discount", - "Введите новую скидку для уведомления на 2-3 день (%).", - ) - - -@router.callback_query(F.data == "admin_mon_edit_notif_day23_valid") -@admin_required -async def edit_day23_valid(callback: CallbackQuery, state: FSMContext): - await _start_waiting_for_value( - callback, - state, - "day23_valid", - "Введите срок действия предложения для 2-3 дня (в часах).", - ) - - -@router.callback_query(F.data == "admin_mon_edit_notif_dayn_discount") -@admin_required -async def edit_dayn_discount(callback: CallbackQuery, state: FSMContext): - await _start_waiting_for_value( - callback, - state, - "dayn_discount", - "Введите новую скидку для уведомления после N дней (%).", - ) - - -@router.callback_query(F.data == "admin_mon_edit_notif_dayn_valid") -@admin_required -async def edit_dayn_valid(callback: CallbackQuery, state: FSMContext): - await _start_waiting_for_value( - callback, - state, - "dayn_valid", - "Введите срок действия предложения после N дней (в часах).", - ) - - -@router.callback_query(F.data == "admin_mon_edit_notif_dayn_threshold") -@admin_required -async def edit_dayn_threshold(callback: CallbackQuery, state: FSMContext): - await _start_waiting_for_value( - callback, - state, - "dayn_threshold", - "Введите через сколько дней после окончания отправлять усиленную скидку (минимум 4).", - ) - - @router.callback_query(F.data == "admin_mon_statistics") @admin_required async def monitoring_statistics_callback(callback: CallbackQuery): @@ -472,47 +286,6 @@ async def monitoring_statistics_callback(callback: CallbackQuery): await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True) -@router.message(NotificationSettingsStates.waiting_for_value) -@admin_required -async def notification_setting_value(message: Message, state: FSMContext): - data = await state.get_data() - param = data.get("param") - value_raw = (message.text or "").strip() - - try: - value_int = int(value_raw) - except ValueError: - await message.answer("❌ Введите целое число.") - return - - if param == "day23_discount": - success = NotificationSettingsService.set_expired_day23_discount_percent(value_int) - result_text = "Скидка для уведомлений на 2-3 день обновлена." - elif param == "day23_valid": - success = NotificationSettingsService.set_expired_day23_valid_hours(value_int) - result_text = "Срок действия предложения на 2-3 день обновлён." - elif param == "dayn_discount": - success = NotificationSettingsService.set_expired_dayn_discount_percent(value_int) - result_text = "Скидка для уведомлений после N дней обновлена." - elif param == "dayn_valid": - success = NotificationSettingsService.set_expired_dayn_valid_hours(value_int) - result_text = "Срок действия предложения после N дней обновлён." - elif param == "dayn_threshold": - success = NotificationSettingsService.set_expired_dayn_threshold_days(value_int) - result_text = "Порог дней для усиленной скидки обновлён." - else: - success = False - result_text = "Неизвестный параметр." - - if success: - await message.answer(f"✅ {result_text}") - text, keyboard = _get_notification_settings_view() - await message.answer(text, parse_mode="HTML", reply_markup=keyboard) - await state.clear() - else: - await message.answer("❌ Не удалось сохранить значение. Попробуйте ещё раз.") - - def get_monitoring_logs_keyboard(current_page: int, total_pages: int): from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index b21d0d67..8219147b 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -920,8 +920,7 @@ def get_monitoring_status_keyboard( keyboard.append(info_row) test_row = [ - InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"), - InlineKeyboardButton(text="🔔 Настройки уведомлений", callback_data="admin_mon_toggle_notifications"), + InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications") ] keyboard.append(test_row) @@ -947,65 +946,6 @@ def get_monitoring_settings_keyboard() -> InlineKeyboardMarkup: ]) -def get_monitoring_notification_settings_keyboard(settings_data: dict) -> InlineKeyboardMarkup: - def _status(enabled: bool) -> str: - return "🟢" if enabled else "🔴" - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=f"{_status(settings_data.get('trial_inactive_1h_enabled'))} Триал · 1 час", - callback_data="admin_mon_toggle_notif_trial1h", - ), - InlineKeyboardButton( - text=f"{_status(settings_data.get('trial_inactive_24h_enabled'))} Триал · 24 часа", - callback_data="admin_mon_toggle_notif_trial24h", - ), - ], - [ - InlineKeyboardButton( - text=f"{_status(settings_data.get('expired_day1_enabled'))} Истекла · 1 день", - callback_data="admin_mon_toggle_notif_expired_day1", - ), - ], - [ - InlineKeyboardButton( - text=f"{_status(settings_data.get('expired_day23_enabled'))} Истекла · 2-3 дня", - callback_data="admin_mon_toggle_notif_expired_day23", - ), - InlineKeyboardButton( - text=f"✏️ Скидка {settings_data.get('expired_day23_discount_percent', 0)}%", - callback_data="admin_mon_edit_notif_day23_discount", - ), - InlineKeyboardButton( - text=f"⏳ {settings_data.get('expired_day23_valid_hours', 0)} ч", - callback_data="admin_mon_edit_notif_day23_valid", - ), - ], - [ - InlineKeyboardButton( - text=f"{_status(settings_data.get('expired_dayn_enabled'))} Истекла · N дней", - callback_data="admin_mon_toggle_notif_expired_dayn", - ), - InlineKeyboardButton( - text=f"✏️ Скидка {settings_data.get('expired_dayn_discount_percent', 0)}%", - callback_data="admin_mon_edit_notif_dayn_discount", - ), - InlineKeyboardButton( - text=f"⏳ {settings_data.get('expired_dayn_valid_hours', 0)} ч", - callback_data="admin_mon_edit_notif_dayn_valid", - ), - InlineKeyboardButton( - text=f"📅 от {settings_data.get('expired_dayn_threshold_days', 0)} дн.", - callback_data="admin_mon_edit_notif_dayn_threshold", - ), - ], - [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring"), - ], - ]) - - def get_log_type_filter_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [ diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index f2a96272..a190aec4 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -24,7 +24,6 @@ from app.database.crud.notification import ( from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus from app.services.subscription_service import SubscriptionService from app.services.payment_service import PaymentService -from app.services.notification_settings_service import NotificationSettingsService from app.localization.texts import get_texts from app.external.remnawave_api import ( @@ -81,12 +80,10 @@ class MonitoringService: async for db in get_db(): try: await self._cleanup_notification_cache() - + await self._check_expired_subscriptions(db) - await self._check_expired_followups(db) await self._check_expiring_subscriptions(db) - await self._check_trial_expiring_soon(db) - await self._check_trial_inactive_users(db) + await self._check_trial_expiring_soon(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) await self._sync_with_remnawave(db) @@ -120,7 +117,7 @@ class MonitoringService: async def _check_expired_subscriptions(self, db: AsyncSession): try: expired_subscriptions = await get_expired_subscriptions(db) - + for subscription in expired_subscriptions: from app.database.crud.subscription import expire_subscription await expire_subscription(db, subscription) @@ -144,119 +141,6 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки истёкших подписок: {e}") - async def _check_expired_followups(self, db: AsyncSession): - try: - if not self.bot: - return - - day1_enabled = NotificationSettingsService.is_expired_day1_enabled() - day23_enabled = NotificationSettingsService.is_expired_day23_enabled() - dayn_enabled = NotificationSettingsService.is_expired_dayn_enabled() - - if not any([day1_enabled, day23_enabled, dayn_enabled]): - return - - result = await db.execute( - select(Subscription) - .options(selectinload(Subscription.user)) - .where(Subscription.status == SubscriptionStatus.EXPIRED.value) - ) - expired_subscriptions = result.scalars().all() - - if not expired_subscriptions: - return - - now = datetime.utcnow() - sent_day1 = 0 - sent_day23 = 0 - sent_dayn = 0 - threshold_n = NotificationSettingsService.get_expired_dayn_threshold_days() - discount_day23 = NotificationSettingsService.get_expired_day23_discount_percent() - discount_dayn = NotificationSettingsService.get_expired_dayn_discount_percent() - valid_day23 = NotificationSettingsService.get_expired_day23_valid_hours() - valid_dayn = NotificationSettingsService.get_expired_dayn_valid_hours() - - for subscription in expired_subscriptions: - user = subscription.user - if not user: - continue - - delta = now - subscription.end_date - if delta.total_seconds() < 0: - continue - - days_since = int(delta.total_seconds() // 86400) - - if days_since < 1: - continue - - if ( - day1_enabled - and days_since == 1 - and not await notification_sent(db, user.id, subscription.id, "expired_followup_day1", days_since) - ): - success = await self._send_expired_followup_notification( - user, - subscription, - "day1", - days_since=days_since, - ) - if success: - await record_notification(db, user.id, subscription.id, "expired_followup_day1", days_since) - sent_day1 += 1 - - if ( - day23_enabled - and days_since in {2, 3} - and not await notification_sent(db, user.id, subscription.id, "expired_followup_day23", days_since) - ): - success = await self._send_expired_followup_notification( - user, - subscription, - "day23", - days_since=days_since, - discount_percent=discount_day23, - valid_hours=valid_day23, - ) - if success: - await record_notification(db, user.id, subscription.id, "expired_followup_day23", days_since) - sent_day23 += 1 - - if ( - dayn_enabled - and days_since >= threshold_n - and not await notification_sent(db, user.id, subscription.id, "expired_followup_dayn", days_since) - ): - success = await self._send_expired_followup_notification( - user, - subscription, - "dayn", - days_since=days_since, - discount_percent=discount_dayn, - valid_hours=valid_dayn, - threshold_days=threshold_n, - ) - if success: - await record_notification(db, user.id, subscription.id, "expired_followup_dayn", days_since) - sent_dayn += 1 - - total_sent = sent_day1 + sent_day23 + sent_dayn - if total_sent > 0: - await self._log_monitoring_event( - db, - "expired_followup_notifications", - "Отправлены напоминания после окончания подписки", - { - "sent_day1": sent_day1, - "sent_day23": sent_day23, - "sent_dayn": sent_dayn, - "total": total_sent, - }, - ) - - except Exception as e: - logger.error(f"Ошибка отправки последующих уведомлений по истекшим подпискам: {e}") - async def update_remnawave_user( self, db: AsyncSession, @@ -366,7 +250,7 @@ class MonitoringService: async def _check_trial_expiring_soon(self, db: AsyncSession): try: threshold_time = datetime.utcnow() + timedelta(hours=2) - + result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -404,99 +288,7 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}") - - async def _check_trial_inactive_users(self, db: AsyncSession): - try: - if not self.bot: - return - - one_hour_enabled = NotificationSettingsService.is_trial_inactive_1h_enabled() - day_enabled = NotificationSettingsService.is_trial_inactive_24h_enabled() - - if not (one_hour_enabled or day_enabled): - return - - result = await db.execute( - select(Subscription) - .options(selectinload(Subscription.user)) - .where( - Subscription.is_trial == True, - Subscription.first_usage_at.is_(None), - Subscription.start_date.is_not(None), - Subscription.status.in_( - [ - SubscriptionStatus.ACTIVE.value, - SubscriptionStatus.TRIAL.value, - ] - ), - ) - ) - trial_subscriptions = result.scalars().all() - - if not trial_subscriptions: - return - - now = datetime.utcnow() - sent_1h = 0 - sent_24h = 0 - - for subscription in trial_subscriptions: - user = subscription.user - if not user: - continue - - try: - sync_success = await self.subscription_service.sync_subscription_usage(db, subscription) - if sync_success: - await db.refresh(subscription) - except Exception as sync_error: # pragma: no cover - defensive log - logger.debug( - "Не удалось синхронизировать использование триальной подписки %s: %s", - subscription.id, - sync_error, - ) - - if subscription.first_usage_at: - continue - - started_at = subscription.start_date or subscription.created_at - if not started_at: - continue - - time_since_start = now - started_at - - if ( - one_hour_enabled - and time_since_start >= timedelta(hours=1) - and not await notification_sent(db, user.id, subscription.id, "trial_inactive_1h", 0) - ): - success = await self._send_trial_inactive_notification(user, subscription, "1h") - if success: - await record_notification(db, user.id, subscription.id, "trial_inactive_1h", 0) - sent_1h += 1 - - if ( - day_enabled - and time_since_start >= timedelta(days=1) - and not await notification_sent(db, user.id, subscription.id, "trial_inactive_24h", 1) - ): - success = await self._send_trial_inactive_notification(user, subscription, "24h") - if success: - await record_notification(db, user.id, subscription.id, "trial_inactive_24h", 1) - sent_24h += 1 - - total_sent = sent_1h + sent_24h - if total_sent > 0: - await self._log_monitoring_event( - db, - "trial_inactive_notifications", - "Отправлены напоминания о неиспользуемом триале", - {"sent_1h": sent_1h, "sent_24h": sent_24h, "total": total_sent}, - ) - - except Exception as e: - logger.error(f"Ошибка проверки неиспользуемых триалов: {e}") - + async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]: current_time = datetime.utcnow() threshold_date = current_time + timedelta(days=days_before) @@ -673,7 +465,7 @@ class MonitoringService: async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool: try: texts = get_texts(user.language) - + message = f""" 🎁 Тестовая подписка скоро закончится! @@ -709,170 +501,7 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}") return False - - async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, stage: str) -> bool: - try: - language = (user.language or settings.DEFAULT_LANGUAGE).lower() - support_url = settings.get_support_contact_url() - support_text = settings.get_support_contact_display() - - if stage == "1h": - if language.startswith("en"): - message = ( - "👋 Let's set up your VPN\n\n" - "It's been an hour since you activated the trial, but we haven't seen any connections yet.\n\n" - "Tap the button below to add the configuration and start browsing safely." - ) - else: - message = ( - "👋 Давайте подключим VPN\n\n" - "Прошел час после активации тестового доступа, но подключений пока нет.\n\n" - "Нажмите кнопку ниже, чтобы добавить конфигурацию и начать пользоваться сервисом." - ) - else: # 24h stage - if language.startswith("en"): - message = ( - "⏰ Trial is still waiting for you\n\n" - "A whole day has passed and the VPN is still not connected.\n\n" - "Connect now and make the most of the test period — it only takes a minute!" - ) - else: - message = ( - "⏰ Тест все еще не используется\n\n" - "Прошли сутки, но VPN так и не был подключен.\n\n" - "Подключитесь сейчас и успейте воспользоваться тестовым периодом — это занимает меньше минуты!" - ) - - from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup - - buttons = [ - [InlineKeyboardButton(text="🔗 Подключить VPN" if not language.startswith("en") else "🔗 Connect VPN", callback_data="subscription_connect")], - [InlineKeyboardButton(text="📱 Моя подписка" if not language.startswith("en") else "📱 My subscription", callback_data="menu_subscription")], - ] - - if support_url: - buttons.append( - [ - InlineKeyboardButton( - text=("🛟 Поддержка" if not language.startswith("en") else "🛟 Support"), - url=support_url, - ) - ] - ) - elif support_text: - message += f"\n\n💬 {support_text}" - - keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) - - await self.bot.send_message( - user.telegram_id, - message, - parse_mode="HTML", - reply_markup=keyboard, - ) - return True - - except Exception as e: - logger.error(f"Ошибка отправки уведомления о неиспользуемом триале пользователю {user.telegram_id}: {e}") - return False - - async def _send_expired_followup_notification( - self, - user: User, - subscription: Subscription, - stage: str, - *, - days_since: int, - discount_percent: int | None = None, - valid_hours: int | None = None, - threshold_days: int | None = None, - ) -> bool: - try: - language = (user.language or settings.DEFAULT_LANGUAGE).lower() - support_url = settings.get_support_contact_url() - support_text = settings.get_support_contact_display() - - if language.startswith("en"): - if stage == "day1": - message = ( - "📅 Your VPN subscription expired yesterday\n\n" - "Renew now to restore unlimited access.\n" - "Tap a button below — activation is instant." - ) - elif stage == "day23": - message = ( - "🔥 Special return offer\n\n" - f"It's been {days_since} days since the subscription expired.\n" - f"Renew now with a {discount_percent}% discount valid for {valid_hours} hours." - ) - else: - trigger_days = threshold_days or days_since - message = ( - "🎁 Extra discount just for you\n\n" - f"The subscription ended {days_since} days ago.\n" - f"Come back with a {discount_percent}% discount valid for {valid_hours} hours.\n" - f"Offer unlocked after {trigger_days} days without renewal." - ) - extend_text = "⏰ Renew subscription" - buy_text = "💎 Buy new period" - balance_text = "💳 Top up balance" - support_button_text = "🛟 Support" - else: - if stage == "day1": - message = ( - "📅 Подписка истекла вчера\n\n" - "Продлите доступ прямо сейчас — активация моментальная." - ) - elif stage == "day23": - message = ( - "🔥 Скидка на продление\n\n" - f"Подписка закончилась {days_since} дня назад.\n" - f"Вернитесь со скидкой {discount_percent}% — предложение действует {valid_hours} ч." - ) - else: - trigger_days = threshold_days or days_since - message = ( - "🎁 Дополнительная скидка для возврата\n\n" - f"Прошло {days_since} дней без подписки.\n" - f"Продлите её со скидкой {discount_percent}% в течение {valid_hours} ч.\n" - f"Предложение доступно после {trigger_days} дней без продления." - ) - extend_text = "⏰ Продлить подписку" - buy_text = "💎 Купить новый период" - balance_text = "💳 Пополнить баланс" - support_button_text = "🛟 Поддержка" - - from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup - - buttons = [ - [InlineKeyboardButton(text=extend_text, callback_data="subscription_extend")], - [InlineKeyboardButton(text=buy_text, callback_data="menu_buy")], - [InlineKeyboardButton(text=balance_text, callback_data="balance_topup")], - ] - - if support_url: - buttons.append([ - InlineKeyboardButton(text=support_button_text, url=support_url) - ]) - elif support_text: - message += f"\n\n💬 {support_text}" - - keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) - - await self.bot.send_message( - user.telegram_id, - message, - parse_mode="HTML", - reply_markup=keyboard, - ) - return True - - except Exception as e: - logger.error( - f"Ошибка отправки уведомления о завершившейся подписке пользователю {user.telegram_id}: {e}" - ) - return False - + async def _send_autopay_success_notification(self, user: User, amount: int, days: int): try: texts = get_texts(user.language) diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py deleted file mode 100644 index 99b27e3e..00000000 --- a/app/services/notification_settings_service.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Runtime storage for user notification preferences.""" -from __future__ import annotations - -import json -import logging -from pathlib import Path -from typing import Any, Dict - -logger = logging.getLogger(__name__) - - -class NotificationSettingsService: - """Manage runtime-configurable notification settings. - - Values are stored in ``data/notification_settings.json`` and can be - modified from the admin panel without restarting the bot. - """ - - _storage_path: Path = Path("data/notification_settings.json") - _data: Dict[str, Any] = {} - _loaded: bool = False - - _defaults: Dict[str, Any] = { - "trial_inactive_1h_enabled": True, - "trial_inactive_24h_enabled": True, - "expired_day1_enabled": True, - "expired_day23_enabled": True, - "expired_day23_discount_percent": 20, - "expired_day23_valid_hours": 24, - "expired_dayn_enabled": True, - "expired_dayn_discount_percent": 30, - "expired_dayn_valid_hours": 24, - "expired_dayn_threshold_days": 5, - } - - @classmethod - def _ensure_storage_dir(cls) -> None: - try: - cls._storage_path.parent.mkdir(parents=True, exist_ok=True) - except Exception as exc: # pragma: no cover - defensive logging - logger.error("Failed to create notification settings directory: %s", exc) - - @classmethod - def _load(cls) -> None: - if cls._loaded: - return - - cls._ensure_storage_dir() - if cls._storage_path.exists(): - try: - cls._data = json.loads(cls._storage_path.read_text(encoding="utf-8")) - except Exception as exc: - logger.error("Failed to load notification settings: %s", exc) - cls._data = {} - else: - cls._data = {} - - cls._loaded = True - - @classmethod - def _save(cls) -> bool: - cls._ensure_storage_dir() - try: - cls._storage_path.write_text( - json.dumps(cls._data, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - return True - except Exception as exc: # pragma: no cover - defensive logging - logger.error("Failed to save notification settings: %s", exc) - return False - - # Helper accessors ----------------------------------------------------- - @classmethod - def _get_bool(cls, key: str) -> bool: - cls._load() - if key in cls._data: - return bool(cls._data[key]) - return bool(cls._defaults.get(key, False)) - - @classmethod - def _set_bool(cls, key: str, value: bool) -> bool: - cls._load() - cls._data[key] = bool(value) - return cls._save() - - @classmethod - def _get_int(cls, key: str) -> int: - cls._load() - if key in cls._data: - try: - return int(cls._data[key]) - except (TypeError, ValueError): - pass - return int(cls._defaults.get(key, 0)) - - @classmethod - def _set_int(cls, key: str, value: int) -> bool: - cls._load() - cls._data[key] = int(value) - return cls._save() - - @classmethod - def get_all(cls) -> Dict[str, Any]: - cls._load() - data = {**cls._defaults, **cls._data} - # cast ints to ensure consistent types - int_keys = [ - "expired_day23_discount_percent", - "expired_day23_valid_hours", - "expired_dayn_discount_percent", - "expired_dayn_valid_hours", - "expired_dayn_threshold_days", - ] - for key in int_keys: - try: - data[key] = int(data[key]) - except (TypeError, ValueError): - data[key] = int(cls._defaults[key]) - bool_keys = [ - "trial_inactive_1h_enabled", - "trial_inactive_24h_enabled", - "expired_day1_enabled", - "expired_day23_enabled", - "expired_dayn_enabled", - ] - for key in bool_keys: - data[key] = bool(data.get(key, cls._defaults[key])) - return data - - # Trial inactivity ----------------------------------------------------- - @classmethod - def is_trial_inactive_1h_enabled(cls) -> bool: - return cls._get_bool("trial_inactive_1h_enabled") - - @classmethod - def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool: - return cls._set_bool("trial_inactive_1h_enabled", enabled) - - @classmethod - def is_trial_inactive_24h_enabled(cls) -> bool: - return cls._get_bool("trial_inactive_24h_enabled") - - @classmethod - def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool: - return cls._set_bool("trial_inactive_24h_enabled", enabled) - - # Expired subscription follow-ups ------------------------------------- - @classmethod - def is_expired_day1_enabled(cls) -> bool: - return cls._get_bool("expired_day1_enabled") - - @classmethod - def set_expired_day1_enabled(cls, enabled: bool) -> bool: - return cls._set_bool("expired_day1_enabled", enabled) - - @classmethod - def is_expired_day23_enabled(cls) -> bool: - return cls._get_bool("expired_day23_enabled") - - @classmethod - def set_expired_day23_enabled(cls, enabled: bool) -> bool: - return cls._set_bool("expired_day23_enabled", enabled) - - @classmethod - def get_expired_day23_discount_percent(cls) -> int: - return max(0, min(100, cls._get_int("expired_day23_discount_percent"))) - - @classmethod - def set_expired_day23_discount_percent(cls, percent: int) -> bool: - percent = max(0, min(100, int(percent))) - return cls._set_int("expired_day23_discount_percent", percent) - - @classmethod - def get_expired_day23_valid_hours(cls) -> int: - return max(1, cls._get_int("expired_day23_valid_hours")) - - @classmethod - def set_expired_day23_valid_hours(cls, hours: int) -> bool: - hours = max(1, int(hours)) - return cls._set_int("expired_day23_valid_hours", hours) - - @classmethod - def is_expired_dayn_enabled(cls) -> bool: - return cls._get_bool("expired_dayn_enabled") - - @classmethod - def set_expired_dayn_enabled(cls, enabled: bool) -> bool: - return cls._set_bool("expired_dayn_enabled", enabled) - - @classmethod - def get_expired_dayn_discount_percent(cls) -> int: - return max(0, min(100, cls._get_int("expired_dayn_discount_percent"))) - - @classmethod - def set_expired_dayn_discount_percent(cls, percent: int) -> bool: - percent = max(0, min(100, int(percent))) - return cls._set_int("expired_dayn_discount_percent", percent) - - @classmethod - def get_expired_dayn_valid_hours(cls) -> int: - return max(1, cls._get_int("expired_dayn_valid_hours")) - - @classmethod - def set_expired_dayn_valid_hours(cls, hours: int) -> bool: - hours = max(1, int(hours)) - return cls._set_int("expired_dayn_valid_hours", hours) - - @classmethod - def get_expired_dayn_threshold_days(cls) -> int: - return max(4, cls._get_int("expired_dayn_threshold_days")) - - @classmethod - def set_expired_dayn_threshold_days(cls, days: int) -> bool: - days = max(4, int(days)) - return cls._set_int("expired_dayn_threshold_days", days) diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index a3a6fc07..e21e259c 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -268,15 +268,12 @@ class SubscriptionService: remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid) if not remnawave_user: return False - + used_gb = self._bytes_to_gb(remnawave_user.used_traffic_bytes) subscription.traffic_used_gb = used_gb - - if used_gb > 0 and not subscription.first_usage_at: - subscription.first_usage_at = datetime.utcnow() - + await db.commit() - + logger.debug(f"Синхронизирован трафик для подписки {subscription.id}: {used_gb} ГБ") return True diff --git a/app/states.py b/app/states.py index 3887dfc7..45e87e21 100644 --- a/app/states.py +++ b/app/states.py @@ -121,10 +121,6 @@ class AdminTicketStates(StatesGroup): class SupportSettingsStates(StatesGroup): waiting_for_desc = State() - -class NotificationSettingsStates(StatesGroup): - waiting_for_value = State() - class AutoPayStates(StatesGroup): setting_autopay_days = State() confirming_autopay_toggle = State() From 1e3c713c954eb21d02d4fbdb343973c4d5788e3c Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:10:50 +0300 Subject: [PATCH 032/146] Add subscription follow-up notifications and admin controls --- app/database/crud/discount_offer.py | 90 +++++ app/database/models.py | 27 +- app/database/universal_migration.py | 95 +++++ app/handlers/admin/monitoring.py | 289 ++++++++++++++ app/handlers/subscription.py | 82 +++- app/services/monitoring_service.py | 360 +++++++++++++++++- app/services/notification_settings_service.py | 249 ++++++++++++ app/states.py | 5 +- locales/en.json | 20 +- locales/ru.json | 20 +- 10 files changed, 1223 insertions(+), 14 deletions(-) create mode 100644 app/database/crud/discount_offer.py create mode 100644 app/services/notification_settings_service.py diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py new file mode 100644 index 00000000..eaa789ae --- /dev/null +++ b/app/database/crud/discount_offer.py @@ -0,0 +1,90 @@ +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import DiscountOffer + + +async def upsert_discount_offer( + db: AsyncSession, + *, + user_id: int, + subscription_id: Optional[int], + notification_type: str, + discount_percent: int, + bonus_amount_kopeks: int, + valid_hours: int, +) -> DiscountOffer: + """Create or refresh a discount offer for a user.""" + + expires_at = datetime.utcnow() + timedelta(hours=valid_hours) + + result = await db.execute( + select(DiscountOffer) + .where( + DiscountOffer.user_id == user_id, + DiscountOffer.notification_type == notification_type, + DiscountOffer.is_active == True, # noqa: E712 + ) + .order_by(DiscountOffer.created_at.desc()) + ) + offer = result.scalars().first() + + if offer and offer.claimed_at is None: + offer.discount_percent = discount_percent + offer.bonus_amount_kopeks = bonus_amount_kopeks + offer.expires_at = expires_at + offer.subscription_id = subscription_id + else: + offer = DiscountOffer( + user_id=user_id, + subscription_id=subscription_id, + notification_type=notification_type, + discount_percent=discount_percent, + bonus_amount_kopeks=bonus_amount_kopeks, + expires_at=expires_at, + is_active=True, + ) + db.add(offer) + + await db.commit() + await db.refresh(offer) + return offer + + +async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]: + result = await db.execute( + select(DiscountOffer).where(DiscountOffer.id == offer_id) + ) + return result.scalar_one_or_none() + + +async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer: + offer.claimed_at = datetime.utcnow() + offer.is_active = False + await db.commit() + await db.refresh(offer) + return offer + + +async def deactivate_expired_offers(db: AsyncSession) -> int: + now = datetime.utcnow() + result = await db.execute( + select(DiscountOffer).where( + DiscountOffer.is_active == True, # noqa: E712 + DiscountOffer.expires_at < now, + ) + ) + offers = result.scalars().all() + if not offers: + return 0 + + count = 0 + for offer in offers: + offer.is_active = False + count += 1 + + await db.commit() + return count diff --git a/app/database/models.py b/app/database/models.py index f9b6d8ab..91a7a360 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -14,6 +14,7 @@ from sqlalchemy import ( JSON, BigInteger, UniqueConstraint, + Index, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -358,6 +359,7 @@ class User(Base): subscription = relationship("Subscription", back_populates="user", uselist=False) transactions = relationship("Transaction", back_populates="user") referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user") + discount_offers = relationship("DiscountOffer", back_populates="user") lifetime_used_traffic_bytes = Column(BigInteger, default=0) auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) last_remnawave_sync = Column(DateTime, nullable=True) @@ -420,8 +422,9 @@ class Subscription(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) remnawave_short_uuid = Column(String(255), nullable=True) - + user = relationship("User", back_populates="subscription") + discount_offers = relationship("DiscountOffer", back_populates="subscription") @property def is_active(self) -> bool: @@ -765,6 +768,28 @@ class SentNotification(Base): user = relationship("User", backref="sent_notifications") subscription = relationship("Subscription", backref="sent_notifications") + +class DiscountOffer(Base): + __tablename__ = "discount_offers" + __table_args__ = ( + Index("ix_discount_offers_user_type", "user_id", "notification_type"), + ) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True) + notification_type = Column(String(50), nullable=False) + discount_percent = Column(Integer, nullable=False, default=0) + bonus_amount_kopeks = Column(Integer, nullable=False, default=0) + expires_at = Column(DateTime, nullable=False) + claimed_at = Column(DateTime, nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + user = relationship("User", back_populates="discount_offers") + subscription = relationship("Subscription", back_populates="discount_offers") + class BroadcastHistory(Base): __tablename__ = "broadcast_history" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 40273ff4..522747f0 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -520,6 +520,94 @@ async def create_pal24_payments_table(): logger.error(f"Ошибка создания таблицы pal24_payments: {e}") return False + +async def create_discount_offers_table(): + table_exists = await check_table_exists('discount_offers') + if table_exists: + logger.info("Таблица discount_offers уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text(""" + CREATE TABLE discount_offers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + subscription_id INTEGER NULL, + notification_type VARCHAR(50) NOT NULL, + discount_percent INTEGER NOT NULL DEFAULT 0, + bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, + expires_at DATETIME NOT NULL, + claimed_at DATETIME NULL, + is_active BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL + ) + """)) + await conn.execute(text(""" + CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type + ON discount_offers (user_id, notification_type) + """)) + + elif db_type == 'postgresql': + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS discount_offers ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subscription_id INTEGER NULL REFERENCES subscriptions(id) ON DELETE SET NULL, + notification_type VARCHAR(50) NOT NULL, + discount_percent INTEGER NOT NULL DEFAULT 0, + bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMP NOT NULL, + claimed_at TIMESTAMP NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """)) + await conn.execute(text(""" + CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type + ON discount_offers (user_id, notification_type) + """)) + + elif db_type == 'mysql': + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS discount_offers ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + user_id INTEGER NOT NULL, + subscription_id INTEGER NULL, + notification_type VARCHAR(50) NOT NULL, + discount_percent INTEGER NOT NULL DEFAULT 0, + bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, + expires_at DATETIME NOT NULL, + claimed_at DATETIME NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_discount_offers_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_discount_offers_subscription FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL + ) + """)) + await conn.execute(text(""" + CREATE INDEX ix_discount_offers_user_type + ON discount_offers (user_id, notification_type) + """)) + + else: + raise ValueError(f"Unsupported database type: {db_type}") + + logger.info("✅ Таблица discount_offers успешно создана") + return True + + except Exception as e: + logger.error(f"Ошибка создания таблицы discount_offers: {e}") + return False + async def create_user_messages_table(): table_exists = await check_table_exists('user_messages') if table_exists: @@ -1467,6 +1555,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей Pal24 payments") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===") + discount_created = await create_discount_offers_table() + if discount_created: + logger.info("✅ Таблица discount_offers готова") + else: + logger.warning("⚠️ Проблемы с таблицей discount_offers") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===") user_messages_created = await create_user_messages_table() if user_messages_created: diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index be876876..2c1066b4 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from aiogram import Router, F from aiogram.types import Message, CallbackQuery from aiogram.filters import Command +from aiogram.fsm.context import FSMContext from app.config import settings from app.database.database import get_db @@ -12,11 +13,77 @@ from app.utils.decorators import admin_required from app.utils.pagination import paginate_list from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard from app.localization.texts import get_texts +from app.services.notification_settings_service import NotificationSettingsService +from app.states import AdminStates logger = logging.getLogger(__name__) router = Router() +def _format_toggle(enabled: bool) -> str: + return "🟢 Вкл" if enabled else "🔴 Выкл" + + +def _build_notification_settings_view(language: str): + texts = get_texts(language) + config = NotificationSettingsService.get_config() + + second_percent = NotificationSettingsService.get_second_wave_discount_percent() + second_hours = NotificationSettingsService.get_second_wave_valid_hours() + third_percent = NotificationSettingsService.get_third_wave_discount_percent() + third_hours = NotificationSettingsService.get_third_wave_valid_hours() + third_days = NotificationSettingsService.get_third_wave_trigger_days() + + trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True)) + trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True)) + expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True)) + second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True)) + third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True)) + + summary_text = ( + "🔔 Уведомления пользователям\n\n" + f"• 1 час после триала: {trial_1h_status}\n" + f"• 24 часа после триала: {trial_24h_status}\n" + f"• 1 день после истечения: {expired_1d_status}\n" + f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n" + f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}" + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")], + [InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")], + [InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")], + [InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")], + [InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")], + [InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")], + [InlineKeyboardButton(text=f"{third_wave_status} • {third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")], + [InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")], + [InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")], + [InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")], + ]) + + return summary_text, keyboard + + +async def _render_notification_settings(callback: CallbackQuery) -> None: + language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE) + text, keyboard = _build_notification_settings_view(language) + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + + +async def _render_notification_settings_for_state(bot, chat_id: int, message_id: int, language: str) -> None: + text, keyboard = _build_notification_settings_view(language) + await bot.edit_message_text( + text, + chat_id, + message_id, + parse_mode="HTML", + reply_markup=keyboard, + ) + @router.callback_query(F.data == "admin_monitoring") @admin_required async def admin_monitoring_menu(callback: CallbackQuery): @@ -52,6 +119,180 @@ async def admin_monitoring_menu(callback: CallbackQuery): await callback.answer("❌ Ошибка получения данных", show_alert=True) +@router.callback_query(F.data == "admin_mon_settings") +@admin_required +async def admin_monitoring_settings(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + global_status = "🟢 Включены" if NotificationSettingsService.are_notifications_globally_enabled() else "🔴 Отключены" + second_percent = NotificationSettingsService.get_second_wave_discount_percent() + third_percent = NotificationSettingsService.get_third_wave_discount_percent() + third_days = NotificationSettingsService.get_third_wave_trigger_days() + + text = ( + "⚙️ Настройки мониторинга\n\n" + f"🔔 Уведомления пользователям: {global_status}\n" + f"• Скидка 2-3 дня: {second_percent}%\n" + f"• Скидка после {third_days} дней: {third_percent}%\n\n" + "Выберите раздел для настройки." + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔔 Уведомления пользователям", callback_data="admin_mon_notify_settings")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")], + ]) + + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + + except Exception as e: + logger.error(f"Ошибка отображения настроек мониторинга: {e}") + await callback.answer("❌ Не удалось открыть настройки", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_settings") +@admin_required +async def admin_notify_settings(callback: CallbackQuery): + try: + await _render_notification_settings(callback) + except Exception as e: + logger.error(f"Ошибка отображения настроек уведомлений: {e}") + await callback.answer("❌ Не удалось загрузить настройки", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_trial_1h") +@admin_required +async def toggle_trial_1h_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_trial_inactive_1h_enabled() + NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h") +@admin_required +async def toggle_trial_24h_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_trial_inactive_24h_enabled() + NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d") +@admin_required +async def toggle_expired_1d_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_expired_1d_enabled() + NotificationSettingsService.set_expired_1d_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d") +@admin_required +async def toggle_second_wave_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_second_wave_enabled() + NotificationSettingsService.set_second_wave_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd") +@admin_required +async def toggle_third_wave_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_third_wave_enabled() + NotificationSettingsService.set_third_wave_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +async def _start_notification_value_edit( + callback: CallbackQuery, + state: FSMContext, + setting_key: str, + field: str, + prompt_key: str, + default_prompt: str, +): + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await state.set_state(AdminStates.editing_notification_value) + await state.update_data( + notification_setting_key=setting_key, + notification_setting_field=field, + settings_message_chat=callback.message.chat.id, + settings_message_id=callback.message.message_id, + settings_language=language, + ) + texts = get_texts(language) + await callback.answer() + await callback.message.answer(texts.get(prompt_key, default_prompt)) + + +@router.callback_query(F.data == "admin_mon_notify_edit_2d_percent") +@admin_required +async def edit_second_wave_percent(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_second_wave", + "percent", + "NOTIFY_PROMPT_SECOND_PERCENT", + "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_2d_hours") +@admin_required +async def edit_second_wave_hours(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_second_wave", + "hours", + "NOTIFY_PROMPT_SECOND_HOURS", + "Введите количество часов действия скидки (1-168):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_nd_percent") +@admin_required +async def edit_third_wave_percent(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_third_wave", + "percent", + "NOTIFY_PROMPT_THIRD_PERCENT", + "Введите новый процент скидки для позднего предложения (0-100):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_nd_hours") +@admin_required +async def edit_third_wave_hours(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_third_wave", + "hours", + "NOTIFY_PROMPT_THIRD_HOURS", + "Введите количество часов действия скидки (1-168):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_nd_threshold") +@admin_required +async def edit_third_wave_threshold(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_third_wave", + "trigger", + "NOTIFY_PROMPT_THIRD_DAYS", + "Через сколько дней после истечения отправлять предложение? (минимум 2):", + ) + + @router.callback_query(F.data == "admin_mon_start") @admin_required async def start_monitoring_callback(callback: CallbackQuery): @@ -366,5 +607,53 @@ async def monitoring_command(message: Message): await message.answer(f"❌ Ошибка: {str(e)}") +@router.message(AdminStates.editing_notification_value) +async def process_notification_value_input(message: Message, state: FSMContext): + data = await state.get_data() + if not data: + await state.clear() + await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.") + return + + raw_value = (message.text or "").strip() + try: + value = int(raw_value) + except (TypeError, ValueError): + language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE + texts = get_texts(language) + await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Введите целое число.")) + return + + key = data.get("notification_setting_key") + field = data.get("notification_setting_field") + language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE + texts = get_texts(language) + + success = False + if key == "expired_second_wave" and field == "percent": + success = NotificationSettingsService.set_second_wave_discount_percent(value) + elif key == "expired_second_wave" and field == "hours": + success = NotificationSettingsService.set_second_wave_valid_hours(value) + elif key == "expired_third_wave" and field == "percent": + success = NotificationSettingsService.set_third_wave_discount_percent(value) + elif key == "expired_third_wave" and field == "hours": + success = NotificationSettingsService.set_third_wave_valid_hours(value) + elif key == "expired_third_wave" and field == "trigger": + success = NotificationSettingsService.set_third_wave_trigger_days(value) + + if not success: + await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова.")) + return + + await message.answer(texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены.")) + + chat_id = data.get("settings_message_chat") + message_id = data.get("settings_message_id") + if chat_id and message_id: + await _render_notification_settings_for_state(message.bot, chat_id, message_id, language) + + await state.clear() + + def register_handlers(dp): dp.include_router(router) \ No newline at end of file diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 3f0c182a..3eeee497 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -17,12 +17,13 @@ from app.database.crud.subscription import ( add_subscription_squad, update_subscription_autopay, add_subscription_servers ) -from app.database.crud.user import subtract_user_balance +from app.database.crud.user import subtract_user_balance, add_user_balance from app.database.crud.transaction import create_transaction, get_user_transactions from app.database.models import ( - User, TransactionType, SubscriptionStatus, - SubscriptionServer, Subscription + User, TransactionType, SubscriptionStatus, + SubscriptionServer, Subscription ) +from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed from app.keyboards.inline import ( get_subscription_keyboard, get_trial_keyboard, get_subscription_period_keyboard, get_traffic_packages_keyboard, @@ -4068,6 +4069,76 @@ async def handle_connect_subscription( await callback.answer() +async def claim_discount_offer( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + try: + offer_id = int(callback.data.split("_")[-1]) + except (ValueError, AttributeError): + await callback.answer( + texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), + show_alert=True, + ) + return + + offer = await get_offer_by_id(db, offer_id) + if not offer or offer.user_id != db_user.id: + await callback.answer( + texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), + show_alert=True, + ) + return + + now = datetime.utcnow() + if offer.claimed_at is not None: + await callback.answer( + texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"), + show_alert=True, + ) + return + + if not offer.is_active or offer.expires_at <= now: + offer.is_active = False + await db.commit() + await callback.answer( + texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"), + show_alert=True, + ) + return + + bonus_amount = offer.bonus_amount_kopeks or 0 + if bonus_amount > 0: + success = await add_user_balance( + db, + db_user, + bonus_amount, + texts.get("DISCOUNT_BONUS_DESCRIPTION", "Скидка за продление подписки"), + ) + if not success: + await callback.answer( + texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось начислить скидку. Попробуйте позже."), + show_alert=True, + ) + return + + await mark_offer_claimed(db, offer) + + success_message = texts.get( + "DISCOUNT_CLAIM_SUCCESS", + "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + ).format( + percent=offer.discount_percent, + amount=settings.format_price(bonus_amount), + ) + + await callback.answer("✅ Скидка активирована!", show_alert=True) + await callback.message.answer(success_message) + + async def handle_device_guide( callback: types.CallbackQuery, db_user: User, @@ -4963,6 +5034,11 @@ def register_handlers(dp: Dispatcher): F.data == "countries_apply" ) + dp.callback_query.register( + claim_discount_offer, + F.data.startswith("claim_discount_") + ) + dp.callback_query.register( handle_connect_subscription, F.data == "subscription_connect" diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index a190aec4..337e18f8 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -21,10 +21,15 @@ from app.database.crud.notification import ( notification_sent, record_notification, ) +from app.database.crud.discount_offer import ( + upsert_discount_offer, + deactivate_expired_offers, +) from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus from app.services.subscription_service import SubscriptionService from app.services.payment_service import PaymentService from app.localization.texts import get_texts +from app.services.notification_settings_service import NotificationSettingsService from app.external.remnawave_api import ( RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError @@ -80,10 +85,16 @@ class MonitoringService: async for db in get_db(): try: await self._cleanup_notification_cache() - + + expired_offers = await deactivate_expired_offers(db) + if expired_offers: + logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений") + await self._check_expired_subscriptions(db) await self._check_expiring_subscriptions(db) - await self._check_trial_expiring_soon(db) + await self._check_trial_expiring_soon(db) + await self._check_trial_inactivity_notifications(db) + await self._check_expired_subscription_followups(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) await self._sync_with_remnawave(db) @@ -250,7 +261,7 @@ class MonitoringService: async def _check_trial_expiring_soon(self, db: AsyncSession): try: threshold_time = datetime.utcnow() + timedelta(hours=2) - + result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -288,7 +299,202 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}") - + + async def _check_trial_inactivity_notifications(self, db: AsyncSession): + if not NotificationSettingsService.are_notifications_globally_enabled(): + return + if not self.bot: + return + + try: + now = datetime.utcnow() + one_hour_ago = now - timedelta(hours=1) + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.is_trial == True, + Subscription.start_date.isnot(None), + Subscription.start_date <= one_hour_ago, + Subscription.end_date > now, + ) + ) + ) + + subscriptions = result.scalars().all() + sent_1h = 0 + sent_24h = 0 + + for subscription in subscriptions: + user = subscription.user + if not user: + continue + + if (subscription.traffic_used_gb or 0) > 0: + continue + + start_date = subscription.start_date + if not start_date: + continue + + time_since_start = now - start_date + + if (NotificationSettingsService.is_trial_inactive_1h_enabled() + and timedelta(hours=1) <= time_since_start < timedelta(hours=24)): + if not await notification_sent(db, user.id, subscription.id, "trial_inactive_1h"): + success = await self._send_trial_inactive_notification(user, subscription, 1) + if success: + await record_notification(db, user.id, subscription.id, "trial_inactive_1h") + sent_1h += 1 + + if NotificationSettingsService.is_trial_inactive_24h_enabled() and time_since_start >= timedelta(hours=24): + if not await notification_sent(db, user.id, subscription.id, "trial_inactive_24h"): + success = await self._send_trial_inactive_notification(user, subscription, 24) + if success: + await record_notification(db, user.id, subscription.id, "trial_inactive_24h") + sent_24h += 1 + + if sent_1h or sent_24h: + await self._log_monitoring_event( + db, + "trial_inactivity_notifications", + f"Отправлено {sent_1h} уведомлений спустя 1 час и {sent_24h} спустя 24 часа", + {"sent_1h": sent_1h, "sent_24h": sent_24h}, + ) + + except Exception as e: + logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}") + + async def _check_expired_subscription_followups(self, db: AsyncSession): + if not NotificationSettingsService.are_notifications_globally_enabled(): + return + if not self.bot: + return + + try: + now = datetime.utcnow() + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.is_trial == False, + Subscription.end_date <= now, + ) + ) + ) + + subscriptions = result.scalars().all() + sent_day1 = 0 + sent_wave2 = 0 + sent_wave3 = 0 + + for subscription in subscriptions: + user = subscription.user + if not user: + continue + + if subscription.end_date is None: + continue + + time_since_end = now - subscription.end_date + if time_since_end.total_seconds() < 0: + continue + + days_since = time_since_end.total_seconds() / 86400 + + # Day 1 reminder + if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2: + if not await notification_sent(db, user.id, subscription.id, "expired_1d"): + success = await self._send_expired_day1_notification(user, subscription) + if success: + await record_notification(db, user.id, subscription.id, "expired_1d") + sent_day1 += 1 + + # Second wave (2-3 days) discount + if NotificationSettingsService.is_second_wave_enabled() and 2 <= days_since < 4: + if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"): + percent = NotificationSettingsService.get_second_wave_discount_percent() + valid_hours = NotificationSettingsService.get_second_wave_valid_hours() + bonus_amount = settings.PRICE_30_DAYS * percent // 100 + offer = await upsert_discount_offer( + db, + user_id=user.id, + subscription_id=subscription.id, + notification_type="expired_discount_wave2", + discount_percent=percent, + bonus_amount_kopeks=bonus_amount, + valid_hours=valid_hours, + ) + success = await self._send_expired_discount_notification( + user, + subscription, + percent, + offer.expires_at, + offer.id, + "second", + bonus_amount, + ) + if success: + await record_notification(db, user.id, subscription.id, "expired_discount_wave2") + sent_wave2 += 1 + + # Third wave (N days) discount + if NotificationSettingsService.is_third_wave_enabled(): + trigger_days = NotificationSettingsService.get_third_wave_trigger_days() + if trigger_days <= days_since < trigger_days + 1: + if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"): + percent = NotificationSettingsService.get_third_wave_discount_percent() + valid_hours = NotificationSettingsService.get_third_wave_valid_hours() + bonus_amount = settings.PRICE_30_DAYS * percent // 100 + offer = await upsert_discount_offer( + db, + user_id=user.id, + subscription_id=subscription.id, + notification_type="expired_discount_wave3", + discount_percent=percent, + bonus_amount_kopeks=bonus_amount, + valid_hours=valid_hours, + ) + success = await self._send_expired_discount_notification( + user, + subscription, + percent, + offer.expires_at, + offer.id, + "third", + bonus_amount, + trigger_days=trigger_days, + ) + if success: + await record_notification(db, user.id, subscription.id, "expired_discount_wave3") + sent_wave3 += 1 + + if sent_day1 or sent_wave2 or sent_wave3: + await self._log_monitoring_event( + db, + "expired_followups_sent", + ( + "Follow-ups: 1д={0}, скидка 2-3д={1}, скидка N={2}".format( + sent_day1, + sent_wave2, + sent_wave3, + ) + ), + { + "day1": sent_day1, + "wave2": sent_wave2, + "wave3": sent_wave3, + }, + ) + + except Exception as e: + logger.error(f"Ошибка проверки напоминаний об истекшей подписке: {e}") + async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]: current_time = datetime.utcnow() threshold_date = current_time + timedelta(days=days_before) @@ -465,7 +671,7 @@ class MonitoringService: async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool: try: texts = get_texts(user.language) - + message = f""" 🎁 Тестовая подписка скоро закончится! @@ -501,7 +707,149 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}") return False - + + async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, hours: int) -> bool: + try: + texts = get_texts(user.language) + if hours >= 24: + template = texts.get( + "TRIAL_INACTIVE_24H", + ( + "⏳ Вы ещё не подключились к VPN\n\n" + "Прошли сутки с активации тестового периода, но трафик не зафиксирован." + "\n\nНажмите кнопку ниже, чтобы подключиться." + ), + ) + else: + template = texts.get( + "TRIAL_INACTIVE_1H", + ( + "⏳ Прошёл час, а подключения нет\n\n" + "Если возникли сложности с запуском — воспользуйтесь инструкциями." + ), + ) + + message = template.format( + price=settings.format_price(settings.PRICE_30_DAYS), + end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], + [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], + ]) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error(f"Ошибка отправки уведомления об отсутствии подключения пользователю {user.telegram_id}: {e}") + return False + + async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool: + try: + texts = get_texts(user.language) + template = texts.get( + "SUBSCRIPTION_EXPIRED_1D", + ( + "⛔ Подписка закончилась\n\n" + "Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис." + ), + ) + message = template.format( + end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + price=settings.format_price(settings.PRICE_30_DAYS), + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")], + [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")], + [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], + ]) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error(f"Ошибка отправки напоминания об истекшей подписке пользователю {user.telegram_id}: {e}") + return False + + async def _send_expired_discount_notification( + self, + user: User, + subscription: Subscription, + percent: int, + expires_at: datetime, + offer_id: int, + wave: str, + bonus_amount: int, + trigger_days: int = None, + ) -> bool: + try: + texts = get_texts(user.language) + + if wave == "second": + template = texts.get( + "SUBSCRIPTION_EXPIRED_SECOND_WAVE", + ( + "🔥 Скидка {percent}% на продление\n\n" + "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. " + "Предложение действует до {expires_at}." + ), + ) + else: + template = texts.get( + "SUBSCRIPTION_EXPIRED_THIRD_WAVE", + ( + "🎁 Индивидуальная скидка {percent}%\n\n" + "Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. " + "Скидка действует до {expires_at}." + ), + ) + + message = template.format( + percent=percent, + bonus=settings.format_price(bonus_amount), + expires_at=expires_at.strftime("%d.%m.%Y %H:%M"), + trigger_days=trigger_days or "", + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎁 Получить скидку", callback_data=f"claim_discount_{offer_id}")], + [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")], + [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")], + [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], + ]) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error(f"Ошибка отправки скидочного уведомления пользователю {user.telegram_id}: {e}") + return False + async def _send_autopay_success_notification(self, user: User, amount: int, days: int): try: texts = get_texts(user.language) diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py new file mode 100644 index 00000000..a19edffd --- /dev/null +++ b/app/services/notification_settings_service.py @@ -0,0 +1,249 @@ +import json +import json +import logging +from copy import deepcopy +from pathlib import Path +from typing import Any, Dict + +from app.config import settings + + +logger = logging.getLogger(__name__) + + +class NotificationSettingsService: + """Runtime-editable notification settings stored on disk.""" + + _storage_path: Path = Path("data/notification_settings.json") + _data: Dict[str, Dict[str, Any]] = {} + _loaded: bool = False + + _DEFAULTS: Dict[str, Dict[str, Any]] = { + "trial_inactive_1h": {"enabled": True}, + "trial_inactive_24h": {"enabled": True}, + "expired_1d": {"enabled": True}, + "expired_second_wave": { + "enabled": True, + "discount_percent": 10, + "valid_hours": 24, + }, + "expired_third_wave": { + "enabled": True, + "discount_percent": 20, + "valid_hours": 24, + "trigger_days": 5, + }, + } + + @classmethod + def _ensure_dir(cls) -> None: + try: + cls._storage_path.parent.mkdir(parents=True, exist_ok=True) + except Exception as exc: # pragma: no cover - filesystem guard + logger.error("Failed to create notification settings dir: %s", exc) + + @classmethod + def _load(cls) -> None: + if cls._loaded: + return + + cls._ensure_dir() + try: + if cls._storage_path.exists(): + raw = cls._storage_path.read_text(encoding="utf-8") + cls._data = json.loads(raw) if raw.strip() else {} + else: + cls._data = {} + except Exception as exc: + logger.error("Failed to load notification settings: %s", exc) + cls._data = {} + + changed = cls._apply_defaults() + if changed: + cls._save() + cls._loaded = True + + @classmethod + def _apply_defaults(cls) -> bool: + changed = False + for key, defaults in cls._DEFAULTS.items(): + current = cls._data.get(key) + if not isinstance(current, dict): + cls._data[key] = deepcopy(defaults) + changed = True + continue + + for def_key, def_value in defaults.items(): + if def_key not in current: + current[def_key] = def_value + changed = True + return changed + + @classmethod + def _save(cls) -> bool: + cls._ensure_dir() + try: + cls._storage_path.write_text( + json.dumps(cls._data, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return True + except Exception as exc: + logger.error("Failed to save notification settings: %s", exc) + return False + + @classmethod + def _get(cls, key: str) -> Dict[str, Any]: + cls._load() + value = cls._data.get(key) + if not isinstance(value, dict): + value = deepcopy(cls._DEFAULTS.get(key, {})) + cls._data[key] = value + return value + + @classmethod + def get_config(cls) -> Dict[str, Dict[str, Any]]: + cls._load() + return deepcopy(cls._data) + + @classmethod + def _set_field(cls, key: str, field: str, value: Any) -> bool: + cls._load() + section = cls._get(key) + section[field] = value + cls._data[key] = section + return cls._save() + + @classmethod + def set_enabled(cls, key: str, enabled: bool) -> bool: + return cls._set_field(key, "enabled", bool(enabled)) + + @classmethod + def is_enabled(cls, key: str) -> bool: + return bool(cls._get(key).get("enabled", True)) + + # Trial inactivity helpers + @classmethod + def is_trial_inactive_1h_enabled(cls) -> bool: + return cls.is_enabled("trial_inactive_1h") + + @classmethod + def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("trial_inactive_1h", enabled) + + @classmethod + def is_trial_inactive_24h_enabled(cls) -> bool: + return cls.is_enabled("trial_inactive_24h") + + @classmethod + def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("trial_inactive_24h", enabled) + + # Expired subscription notifications + @classmethod + def is_expired_1d_enabled(cls) -> bool: + return cls.is_enabled("expired_1d") + + @classmethod + def set_expired_1d_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("expired_1d", enabled) + + @classmethod + def is_second_wave_enabled(cls) -> bool: + return cls.is_enabled("expired_second_wave") + + @classmethod + def set_second_wave_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("expired_second_wave", enabled) + + @classmethod + def get_second_wave_discount_percent(cls) -> int: + value = cls._get("expired_second_wave").get("discount_percent", 10) + try: + return max(0, min(100, int(value))) + except (TypeError, ValueError): + return 10 + + @classmethod + def set_second_wave_discount_percent(cls, percent: int) -> bool: + try: + percent_int = max(0, min(100, int(percent))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_second_wave", "discount_percent", percent_int) + + @classmethod + def get_second_wave_valid_hours(cls) -> int: + value = cls._get("expired_second_wave").get("valid_hours", 24) + try: + return max(1, min(168, int(value))) + except (TypeError, ValueError): + return 24 + + @classmethod + def set_second_wave_valid_hours(cls, hours: int) -> bool: + try: + hours_int = max(1, min(168, int(hours))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_second_wave", "valid_hours", hours_int) + + @classmethod + def is_third_wave_enabled(cls) -> bool: + return cls.is_enabled("expired_third_wave") + + @classmethod + def set_third_wave_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("expired_third_wave", enabled) + + @classmethod + def get_third_wave_discount_percent(cls) -> int: + value = cls._get("expired_third_wave").get("discount_percent", 20) + try: + return max(0, min(100, int(value))) + except (TypeError, ValueError): + return 20 + + @classmethod + def set_third_wave_discount_percent(cls, percent: int) -> bool: + try: + percent_int = max(0, min(100, int(percent))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_third_wave", "discount_percent", percent_int) + + @classmethod + def get_third_wave_valid_hours(cls) -> int: + value = cls._get("expired_third_wave").get("valid_hours", 24) + try: + return max(1, min(168, int(value))) + except (TypeError, ValueError): + return 24 + + @classmethod + def set_third_wave_valid_hours(cls, hours: int) -> bool: + try: + hours_int = max(1, min(168, int(hours))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_third_wave", "valid_hours", hours_int) + + @classmethod + def get_third_wave_trigger_days(cls) -> int: + value = cls._get("expired_third_wave").get("trigger_days", 5) + try: + return max(2, min(60, int(value))) + except (TypeError, ValueError): + return 5 + + @classmethod + def set_third_wave_trigger_days(cls, days: int) -> bool: + try: + days_int = max(2, min(60, int(days))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_third_wave", "trigger_days", days_int) + + @classmethod + def are_notifications_globally_enabled(cls) -> bool: + return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True)) diff --git a/app/states.py b/app/states.py index 45e87e21..782fae7d 100644 --- a/app/states.py +++ b/app/states.py @@ -84,9 +84,10 @@ class AdminStates(StatesGroup): editing_device_price = State() editing_user_devices = State() editing_user_traffic = State() - + editing_rules_page = State() - + editing_notification_value = State() + confirming_sync = State() editing_server_name = State() diff --git a/locales/en.json b/locales/en.json index 1b416564..046a5754 100644 --- a/locales/en.json +++ b/locales/en.json @@ -484,5 +484,23 @@ "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance." + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", + "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!", + "TRIAL_INACTIVE_24H": "⏳ A full day passed without activity\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Your subscription expired\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.", + "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.", + "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", + "NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.", + "NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.", + "NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):", + "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):", + "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):", + "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):" } diff --git a/locales/ru.json b/locales/ru.json index 736d38e1..db152690 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -484,5 +484,23 @@ "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку." + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", + "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!", + "TRIAL_INACTIVE_24H": "⏳ Прошли сутки с начала теста\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Подписка закончилась\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", + "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", + "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", + "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", + "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", + "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", + "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):", + "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):" } From 8d32b1463a9e4a14f6bed6cfade200e04200529b Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:14:33 +0300 Subject: [PATCH 033/146] Revert "Implement subscription follow-up notifications and admin configuration" --- app/database/crud/discount_offer.py | 90 ----- app/database/models.py | 27 +- app/database/universal_migration.py | 95 ----- app/handlers/admin/monitoring.py | 289 -------------- app/handlers/subscription.py | 82 +--- app/services/monitoring_service.py | 360 +----------------- app/services/notification_settings_service.py | 249 ------------ app/states.py | 5 +- locales/en.json | 20 +- locales/ru.json | 20 +- 10 files changed, 14 insertions(+), 1223 deletions(-) delete mode 100644 app/database/crud/discount_offer.py delete mode 100644 app/services/notification_settings_service.py diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py deleted file mode 100644 index eaa789ae..00000000 --- a/app/database/crud/discount_offer.py +++ /dev/null @@ -1,90 +0,0 @@ -from datetime import datetime, timedelta -from typing import Optional - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.models import DiscountOffer - - -async def upsert_discount_offer( - db: AsyncSession, - *, - user_id: int, - subscription_id: Optional[int], - notification_type: str, - discount_percent: int, - bonus_amount_kopeks: int, - valid_hours: int, -) -> DiscountOffer: - """Create or refresh a discount offer for a user.""" - - expires_at = datetime.utcnow() + timedelta(hours=valid_hours) - - result = await db.execute( - select(DiscountOffer) - .where( - DiscountOffer.user_id == user_id, - DiscountOffer.notification_type == notification_type, - DiscountOffer.is_active == True, # noqa: E712 - ) - .order_by(DiscountOffer.created_at.desc()) - ) - offer = result.scalars().first() - - if offer and offer.claimed_at is None: - offer.discount_percent = discount_percent - offer.bonus_amount_kopeks = bonus_amount_kopeks - offer.expires_at = expires_at - offer.subscription_id = subscription_id - else: - offer = DiscountOffer( - user_id=user_id, - subscription_id=subscription_id, - notification_type=notification_type, - discount_percent=discount_percent, - bonus_amount_kopeks=bonus_amount_kopeks, - expires_at=expires_at, - is_active=True, - ) - db.add(offer) - - await db.commit() - await db.refresh(offer) - return offer - - -async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]: - result = await db.execute( - select(DiscountOffer).where(DiscountOffer.id == offer_id) - ) - return result.scalar_one_or_none() - - -async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer: - offer.claimed_at = datetime.utcnow() - offer.is_active = False - await db.commit() - await db.refresh(offer) - return offer - - -async def deactivate_expired_offers(db: AsyncSession) -> int: - now = datetime.utcnow() - result = await db.execute( - select(DiscountOffer).where( - DiscountOffer.is_active == True, # noqa: E712 - DiscountOffer.expires_at < now, - ) - ) - offers = result.scalars().all() - if not offers: - return 0 - - count = 0 - for offer in offers: - offer.is_active = False - count += 1 - - await db.commit() - return count diff --git a/app/database/models.py b/app/database/models.py index 91a7a360..f9b6d8ab 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -14,7 +14,6 @@ from sqlalchemy import ( JSON, BigInteger, UniqueConstraint, - Index, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -359,7 +358,6 @@ class User(Base): subscription = relationship("Subscription", back_populates="user", uselist=False) transactions = relationship("Transaction", back_populates="user") referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user") - discount_offers = relationship("DiscountOffer", back_populates="user") lifetime_used_traffic_bytes = Column(BigInteger, default=0) auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) last_remnawave_sync = Column(DateTime, nullable=True) @@ -422,9 +420,8 @@ class Subscription(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) remnawave_short_uuid = Column(String(255), nullable=True) - + user = relationship("User", back_populates="subscription") - discount_offers = relationship("DiscountOffer", back_populates="subscription") @property def is_active(self) -> bool: @@ -768,28 +765,6 @@ class SentNotification(Base): user = relationship("User", backref="sent_notifications") subscription = relationship("Subscription", backref="sent_notifications") - -class DiscountOffer(Base): - __tablename__ = "discount_offers" - __table_args__ = ( - Index("ix_discount_offers_user_type", "user_id", "notification_type"), - ) - - id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True) - notification_type = Column(String(50), nullable=False) - discount_percent = Column(Integer, nullable=False, default=0) - bonus_amount_kopeks = Column(Integer, nullable=False, default=0) - expires_at = Column(DateTime, nullable=False) - claimed_at = Column(DateTime, nullable=True) - is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - user = relationship("User", back_populates="discount_offers") - subscription = relationship("Subscription", back_populates="discount_offers") - class BroadcastHistory(Base): __tablename__ = "broadcast_history" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 522747f0..40273ff4 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -520,94 +520,6 @@ async def create_pal24_payments_table(): logger.error(f"Ошибка создания таблицы pal24_payments: {e}") return False - -async def create_discount_offers_table(): - table_exists = await check_table_exists('discount_offers') - if table_exists: - logger.info("Таблица discount_offers уже существует") - return True - - try: - async with engine.begin() as conn: - db_type = await get_database_type() - - if db_type == 'sqlite': - await conn.execute(text(""" - CREATE TABLE discount_offers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - subscription_id INTEGER NULL, - notification_type VARCHAR(50) NOT NULL, - discount_percent INTEGER NOT NULL DEFAULT 0, - bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, - expires_at DATETIME NOT NULL, - claimed_at DATETIME NULL, - is_active BOOLEAN NOT NULL DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL - ) - """)) - await conn.execute(text(""" - CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type - ON discount_offers (user_id, notification_type) - """)) - - elif db_type == 'postgresql': - await conn.execute(text(""" - CREATE TABLE IF NOT EXISTS discount_offers ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - subscription_id INTEGER NULL REFERENCES subscriptions(id) ON DELETE SET NULL, - notification_type VARCHAR(50) NOT NULL, - discount_percent INTEGER NOT NULL DEFAULT 0, - bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, - expires_at TIMESTAMP NOT NULL, - claimed_at TIMESTAMP NULL, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """)) - await conn.execute(text(""" - CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type - ON discount_offers (user_id, notification_type) - """)) - - elif db_type == 'mysql': - await conn.execute(text(""" - CREATE TABLE IF NOT EXISTS discount_offers ( - id INTEGER PRIMARY KEY AUTO_INCREMENT, - user_id INTEGER NOT NULL, - subscription_id INTEGER NULL, - notification_type VARCHAR(50) NOT NULL, - discount_percent INTEGER NOT NULL DEFAULT 0, - bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, - expires_at DATETIME NOT NULL, - claimed_at DATETIME NULL, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT fk_discount_offers_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT fk_discount_offers_subscription FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL - ) - """)) - await conn.execute(text(""" - CREATE INDEX ix_discount_offers_user_type - ON discount_offers (user_id, notification_type) - """)) - - else: - raise ValueError(f"Unsupported database type: {db_type}") - - logger.info("✅ Таблица discount_offers успешно создана") - return True - - except Exception as e: - logger.error(f"Ошибка создания таблицы discount_offers: {e}") - return False - async def create_user_messages_table(): table_exists = await check_table_exists('user_messages') if table_exists: @@ -1555,13 +1467,6 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей Pal24 payments") - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===") - discount_created = await create_discount_offers_table() - if discount_created: - logger.info("✅ Таблица discount_offers готова") - else: - logger.warning("⚠️ Проблемы с таблицей discount_offers") - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===") user_messages_created = await create_user_messages_table() if user_messages_created: diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 2c1066b4..be876876 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -4,7 +4,6 @@ from datetime import datetime, timedelta from aiogram import Router, F from aiogram.types import Message, CallbackQuery from aiogram.filters import Command -from aiogram.fsm.context import FSMContext from app.config import settings from app.database.database import get_db @@ -13,77 +12,11 @@ from app.utils.decorators import admin_required from app.utils.pagination import paginate_list from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard from app.localization.texts import get_texts -from app.services.notification_settings_service import NotificationSettingsService -from app.states import AdminStates logger = logging.getLogger(__name__) router = Router() -def _format_toggle(enabled: bool) -> str: - return "🟢 Вкл" if enabled else "🔴 Выкл" - - -def _build_notification_settings_view(language: str): - texts = get_texts(language) - config = NotificationSettingsService.get_config() - - second_percent = NotificationSettingsService.get_second_wave_discount_percent() - second_hours = NotificationSettingsService.get_second_wave_valid_hours() - third_percent = NotificationSettingsService.get_third_wave_discount_percent() - third_hours = NotificationSettingsService.get_third_wave_valid_hours() - third_days = NotificationSettingsService.get_third_wave_trigger_days() - - trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True)) - trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True)) - expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True)) - second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True)) - third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True)) - - summary_text = ( - "🔔 Уведомления пользователям\n\n" - f"• 1 час после триала: {trial_1h_status}\n" - f"• 24 часа после триала: {trial_24h_status}\n" - f"• 1 день после истечения: {expired_1d_status}\n" - f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n" - f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}" - ) - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")], - [InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")], - [InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")], - [InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")], - [InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")], - [InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")], - [InlineKeyboardButton(text=f"{third_wave_status} • {third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")], - [InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")], - [InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")], - [InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")], - ]) - - return summary_text, keyboard - - -async def _render_notification_settings(callback: CallbackQuery) -> None: - language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE) - text, keyboard = _build_notification_settings_view(language) - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) - - -async def _render_notification_settings_for_state(bot, chat_id: int, message_id: int, language: str) -> None: - text, keyboard = _build_notification_settings_view(language) - await bot.edit_message_text( - text, - chat_id, - message_id, - parse_mode="HTML", - reply_markup=keyboard, - ) - @router.callback_query(F.data == "admin_monitoring") @admin_required async def admin_monitoring_menu(callback: CallbackQuery): @@ -119,180 +52,6 @@ async def admin_monitoring_menu(callback: CallbackQuery): await callback.answer("❌ Ошибка получения данных", show_alert=True) -@router.callback_query(F.data == "admin_mon_settings") -@admin_required -async def admin_monitoring_settings(callback: CallbackQuery): - try: - language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE - global_status = "🟢 Включены" if NotificationSettingsService.are_notifications_globally_enabled() else "🔴 Отключены" - second_percent = NotificationSettingsService.get_second_wave_discount_percent() - third_percent = NotificationSettingsService.get_third_wave_discount_percent() - third_days = NotificationSettingsService.get_third_wave_trigger_days() - - text = ( - "⚙️ Настройки мониторинга\n\n" - f"🔔 Уведомления пользователям: {global_status}\n" - f"• Скидка 2-3 дня: {second_percent}%\n" - f"• Скидка после {third_days} дней: {third_percent}%\n\n" - "Выберите раздел для настройки." - ) - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🔔 Уведомления пользователям", callback_data="admin_mon_notify_settings")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")], - ]) - - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) - - except Exception as e: - logger.error(f"Ошибка отображения настроек мониторинга: {e}") - await callback.answer("❌ Не удалось открыть настройки", show_alert=True) - - -@router.callback_query(F.data == "admin_mon_notify_settings") -@admin_required -async def admin_notify_settings(callback: CallbackQuery): - try: - await _render_notification_settings(callback) - except Exception as e: - logger.error(f"Ошибка отображения настроек уведомлений: {e}") - await callback.answer("❌ Не удалось загрузить настройки", show_alert=True) - - -@router.callback_query(F.data == "admin_mon_notify_toggle_trial_1h") -@admin_required -async def toggle_trial_1h_notification(callback: CallbackQuery): - enabled = NotificationSettingsService.is_trial_inactive_1h_enabled() - NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") - await _render_notification_settings(callback) - - -@router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h") -@admin_required -async def toggle_trial_24h_notification(callback: CallbackQuery): - enabled = NotificationSettingsService.is_trial_inactive_24h_enabled() - NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") - await _render_notification_settings(callback) - - -@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d") -@admin_required -async def toggle_expired_1d_notification(callback: CallbackQuery): - enabled = NotificationSettingsService.is_expired_1d_enabled() - NotificationSettingsService.set_expired_1d_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") - await _render_notification_settings(callback) - - -@router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d") -@admin_required -async def toggle_second_wave_notification(callback: CallbackQuery): - enabled = NotificationSettingsService.is_second_wave_enabled() - NotificationSettingsService.set_second_wave_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") - await _render_notification_settings(callback) - - -@router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd") -@admin_required -async def toggle_third_wave_notification(callback: CallbackQuery): - enabled = NotificationSettingsService.is_third_wave_enabled() - NotificationSettingsService.set_third_wave_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") - await _render_notification_settings(callback) - - -async def _start_notification_value_edit( - callback: CallbackQuery, - state: FSMContext, - setting_key: str, - field: str, - prompt_key: str, - default_prompt: str, -): - language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE - await state.set_state(AdminStates.editing_notification_value) - await state.update_data( - notification_setting_key=setting_key, - notification_setting_field=field, - settings_message_chat=callback.message.chat.id, - settings_message_id=callback.message.message_id, - settings_language=language, - ) - texts = get_texts(language) - await callback.answer() - await callback.message.answer(texts.get(prompt_key, default_prompt)) - - -@router.callback_query(F.data == "admin_mon_notify_edit_2d_percent") -@admin_required -async def edit_second_wave_percent(callback: CallbackQuery, state: FSMContext): - await _start_notification_value_edit( - callback, - state, - "expired_second_wave", - "percent", - "NOTIFY_PROMPT_SECOND_PERCENT", - "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", - ) - - -@router.callback_query(F.data == "admin_mon_notify_edit_2d_hours") -@admin_required -async def edit_second_wave_hours(callback: CallbackQuery, state: FSMContext): - await _start_notification_value_edit( - callback, - state, - "expired_second_wave", - "hours", - "NOTIFY_PROMPT_SECOND_HOURS", - "Введите количество часов действия скидки (1-168):", - ) - - -@router.callback_query(F.data == "admin_mon_notify_edit_nd_percent") -@admin_required -async def edit_third_wave_percent(callback: CallbackQuery, state: FSMContext): - await _start_notification_value_edit( - callback, - state, - "expired_third_wave", - "percent", - "NOTIFY_PROMPT_THIRD_PERCENT", - "Введите новый процент скидки для позднего предложения (0-100):", - ) - - -@router.callback_query(F.data == "admin_mon_notify_edit_nd_hours") -@admin_required -async def edit_third_wave_hours(callback: CallbackQuery, state: FSMContext): - await _start_notification_value_edit( - callback, - state, - "expired_third_wave", - "hours", - "NOTIFY_PROMPT_THIRD_HOURS", - "Введите количество часов действия скидки (1-168):", - ) - - -@router.callback_query(F.data == "admin_mon_notify_edit_nd_threshold") -@admin_required -async def edit_third_wave_threshold(callback: CallbackQuery, state: FSMContext): - await _start_notification_value_edit( - callback, - state, - "expired_third_wave", - "trigger", - "NOTIFY_PROMPT_THIRD_DAYS", - "Через сколько дней после истечения отправлять предложение? (минимум 2):", - ) - - @router.callback_query(F.data == "admin_mon_start") @admin_required async def start_monitoring_callback(callback: CallbackQuery): @@ -607,53 +366,5 @@ async def monitoring_command(message: Message): await message.answer(f"❌ Ошибка: {str(e)}") -@router.message(AdminStates.editing_notification_value) -async def process_notification_value_input(message: Message, state: FSMContext): - data = await state.get_data() - if not data: - await state.clear() - await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.") - return - - raw_value = (message.text or "").strip() - try: - value = int(raw_value) - except (TypeError, ValueError): - language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE - texts = get_texts(language) - await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Введите целое число.")) - return - - key = data.get("notification_setting_key") - field = data.get("notification_setting_field") - language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE - texts = get_texts(language) - - success = False - if key == "expired_second_wave" and field == "percent": - success = NotificationSettingsService.set_second_wave_discount_percent(value) - elif key == "expired_second_wave" and field == "hours": - success = NotificationSettingsService.set_second_wave_valid_hours(value) - elif key == "expired_third_wave" and field == "percent": - success = NotificationSettingsService.set_third_wave_discount_percent(value) - elif key == "expired_third_wave" and field == "hours": - success = NotificationSettingsService.set_third_wave_valid_hours(value) - elif key == "expired_third_wave" and field == "trigger": - success = NotificationSettingsService.set_third_wave_trigger_days(value) - - if not success: - await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова.")) - return - - await message.answer(texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены.")) - - chat_id = data.get("settings_message_chat") - message_id = data.get("settings_message_id") - if chat_id and message_id: - await _render_notification_settings_for_state(message.bot, chat_id, message_id, language) - - await state.clear() - - def register_handlers(dp): dp.include_router(router) \ No newline at end of file diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 3eeee497..3f0c182a 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -17,13 +17,12 @@ from app.database.crud.subscription import ( add_subscription_squad, update_subscription_autopay, add_subscription_servers ) -from app.database.crud.user import subtract_user_balance, add_user_balance +from app.database.crud.user import subtract_user_balance from app.database.crud.transaction import create_transaction, get_user_transactions from app.database.models import ( - User, TransactionType, SubscriptionStatus, - SubscriptionServer, Subscription + User, TransactionType, SubscriptionStatus, + SubscriptionServer, Subscription ) -from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed from app.keyboards.inline import ( get_subscription_keyboard, get_trial_keyboard, get_subscription_period_keyboard, get_traffic_packages_keyboard, @@ -4069,76 +4068,6 @@ async def handle_connect_subscription( await callback.answer() -async def claim_discount_offer( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - - try: - offer_id = int(callback.data.split("_")[-1]) - except (ValueError, AttributeError): - await callback.answer( - texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), - show_alert=True, - ) - return - - offer = await get_offer_by_id(db, offer_id) - if not offer or offer.user_id != db_user.id: - await callback.answer( - texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), - show_alert=True, - ) - return - - now = datetime.utcnow() - if offer.claimed_at is not None: - await callback.answer( - texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"), - show_alert=True, - ) - return - - if not offer.is_active or offer.expires_at <= now: - offer.is_active = False - await db.commit() - await callback.answer( - texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"), - show_alert=True, - ) - return - - bonus_amount = offer.bonus_amount_kopeks or 0 - if bonus_amount > 0: - success = await add_user_balance( - db, - db_user, - bonus_amount, - texts.get("DISCOUNT_BONUS_DESCRIPTION", "Скидка за продление подписки"), - ) - if not success: - await callback.answer( - texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось начислить скидку. Попробуйте позже."), - show_alert=True, - ) - return - - await mark_offer_claimed(db, offer) - - success_message = texts.get( - "DISCOUNT_CLAIM_SUCCESS", - "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", - ).format( - percent=offer.discount_percent, - amount=settings.format_price(bonus_amount), - ) - - await callback.answer("✅ Скидка активирована!", show_alert=True) - await callback.message.answer(success_message) - - async def handle_device_guide( callback: types.CallbackQuery, db_user: User, @@ -5034,11 +4963,6 @@ def register_handlers(dp: Dispatcher): F.data == "countries_apply" ) - dp.callback_query.register( - claim_discount_offer, - F.data.startswith("claim_discount_") - ) - dp.callback_query.register( handle_connect_subscription, F.data == "subscription_connect" diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 337e18f8..a190aec4 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -21,15 +21,10 @@ from app.database.crud.notification import ( notification_sent, record_notification, ) -from app.database.crud.discount_offer import ( - upsert_discount_offer, - deactivate_expired_offers, -) from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus from app.services.subscription_service import SubscriptionService from app.services.payment_service import PaymentService from app.localization.texts import get_texts -from app.services.notification_settings_service import NotificationSettingsService from app.external.remnawave_api import ( RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError @@ -85,16 +80,10 @@ class MonitoringService: async for db in get_db(): try: await self._cleanup_notification_cache() - - expired_offers = await deactivate_expired_offers(db) - if expired_offers: - logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений") - + await self._check_expired_subscriptions(db) await self._check_expiring_subscriptions(db) - await self._check_trial_expiring_soon(db) - await self._check_trial_inactivity_notifications(db) - await self._check_expired_subscription_followups(db) + await self._check_trial_expiring_soon(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) await self._sync_with_remnawave(db) @@ -261,7 +250,7 @@ class MonitoringService: async def _check_trial_expiring_soon(self, db: AsyncSession): try: threshold_time = datetime.utcnow() + timedelta(hours=2) - + result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -299,202 +288,7 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}") - - async def _check_trial_inactivity_notifications(self, db: AsyncSession): - if not NotificationSettingsService.are_notifications_globally_enabled(): - return - if not self.bot: - return - - try: - now = datetime.utcnow() - one_hour_ago = now - timedelta(hours=1) - - result = await db.execute( - select(Subscription) - .options(selectinload(Subscription.user)) - .where( - and_( - Subscription.status == SubscriptionStatus.ACTIVE.value, - Subscription.is_trial == True, - Subscription.start_date.isnot(None), - Subscription.start_date <= one_hour_ago, - Subscription.end_date > now, - ) - ) - ) - - subscriptions = result.scalars().all() - sent_1h = 0 - sent_24h = 0 - - for subscription in subscriptions: - user = subscription.user - if not user: - continue - - if (subscription.traffic_used_gb or 0) > 0: - continue - - start_date = subscription.start_date - if not start_date: - continue - - time_since_start = now - start_date - - if (NotificationSettingsService.is_trial_inactive_1h_enabled() - and timedelta(hours=1) <= time_since_start < timedelta(hours=24)): - if not await notification_sent(db, user.id, subscription.id, "trial_inactive_1h"): - success = await self._send_trial_inactive_notification(user, subscription, 1) - if success: - await record_notification(db, user.id, subscription.id, "trial_inactive_1h") - sent_1h += 1 - - if NotificationSettingsService.is_trial_inactive_24h_enabled() and time_since_start >= timedelta(hours=24): - if not await notification_sent(db, user.id, subscription.id, "trial_inactive_24h"): - success = await self._send_trial_inactive_notification(user, subscription, 24) - if success: - await record_notification(db, user.id, subscription.id, "trial_inactive_24h") - sent_24h += 1 - - if sent_1h or sent_24h: - await self._log_monitoring_event( - db, - "trial_inactivity_notifications", - f"Отправлено {sent_1h} уведомлений спустя 1 час и {sent_24h} спустя 24 часа", - {"sent_1h": sent_1h, "sent_24h": sent_24h}, - ) - - except Exception as e: - logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}") - - async def _check_expired_subscription_followups(self, db: AsyncSession): - if not NotificationSettingsService.are_notifications_globally_enabled(): - return - if not self.bot: - return - - try: - now = datetime.utcnow() - - result = await db.execute( - select(Subscription) - .options(selectinload(Subscription.user)) - .where( - and_( - Subscription.is_trial == False, - Subscription.end_date <= now, - ) - ) - ) - - subscriptions = result.scalars().all() - sent_day1 = 0 - sent_wave2 = 0 - sent_wave3 = 0 - - for subscription in subscriptions: - user = subscription.user - if not user: - continue - - if subscription.end_date is None: - continue - - time_since_end = now - subscription.end_date - if time_since_end.total_seconds() < 0: - continue - - days_since = time_since_end.total_seconds() / 86400 - - # Day 1 reminder - if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2: - if not await notification_sent(db, user.id, subscription.id, "expired_1d"): - success = await self._send_expired_day1_notification(user, subscription) - if success: - await record_notification(db, user.id, subscription.id, "expired_1d") - sent_day1 += 1 - - # Second wave (2-3 days) discount - if NotificationSettingsService.is_second_wave_enabled() and 2 <= days_since < 4: - if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"): - percent = NotificationSettingsService.get_second_wave_discount_percent() - valid_hours = NotificationSettingsService.get_second_wave_valid_hours() - bonus_amount = settings.PRICE_30_DAYS * percent // 100 - offer = await upsert_discount_offer( - db, - user_id=user.id, - subscription_id=subscription.id, - notification_type="expired_discount_wave2", - discount_percent=percent, - bonus_amount_kopeks=bonus_amount, - valid_hours=valid_hours, - ) - success = await self._send_expired_discount_notification( - user, - subscription, - percent, - offer.expires_at, - offer.id, - "second", - bonus_amount, - ) - if success: - await record_notification(db, user.id, subscription.id, "expired_discount_wave2") - sent_wave2 += 1 - - # Third wave (N days) discount - if NotificationSettingsService.is_third_wave_enabled(): - trigger_days = NotificationSettingsService.get_third_wave_trigger_days() - if trigger_days <= days_since < trigger_days + 1: - if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"): - percent = NotificationSettingsService.get_third_wave_discount_percent() - valid_hours = NotificationSettingsService.get_third_wave_valid_hours() - bonus_amount = settings.PRICE_30_DAYS * percent // 100 - offer = await upsert_discount_offer( - db, - user_id=user.id, - subscription_id=subscription.id, - notification_type="expired_discount_wave3", - discount_percent=percent, - bonus_amount_kopeks=bonus_amount, - valid_hours=valid_hours, - ) - success = await self._send_expired_discount_notification( - user, - subscription, - percent, - offer.expires_at, - offer.id, - "third", - bonus_amount, - trigger_days=trigger_days, - ) - if success: - await record_notification(db, user.id, subscription.id, "expired_discount_wave3") - sent_wave3 += 1 - - if sent_day1 or sent_wave2 or sent_wave3: - await self._log_monitoring_event( - db, - "expired_followups_sent", - ( - "Follow-ups: 1д={0}, скидка 2-3д={1}, скидка N={2}".format( - sent_day1, - sent_wave2, - sent_wave3, - ) - ), - { - "day1": sent_day1, - "wave2": sent_wave2, - "wave3": sent_wave3, - }, - ) - - except Exception as e: - logger.error(f"Ошибка проверки напоминаний об истекшей подписке: {e}") - + async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]: current_time = datetime.utcnow() threshold_date = current_time + timedelta(days=days_before) @@ -671,7 +465,7 @@ class MonitoringService: async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool: try: texts = get_texts(user.language) - + message = f""" 🎁 Тестовая подписка скоро закончится! @@ -707,149 +501,7 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}") return False - - async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, hours: int) -> bool: - try: - texts = get_texts(user.language) - if hours >= 24: - template = texts.get( - "TRIAL_INACTIVE_24H", - ( - "⏳ Вы ещё не подключились к VPN\n\n" - "Прошли сутки с активации тестового периода, но трафик не зафиксирован." - "\n\nНажмите кнопку ниже, чтобы подключиться." - ), - ) - else: - template = texts.get( - "TRIAL_INACTIVE_1H", - ( - "⏳ Прошёл час, а подключения нет\n\n" - "Если возникли сложности с запуском — воспользуйтесь инструкциями." - ), - ) - - message = template.format( - price=settings.format_price(settings.PRICE_30_DAYS), - end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), - ) - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], - [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], - ]) - - await self.bot.send_message( - user.telegram_id, - message, - parse_mode="HTML", - reply_markup=keyboard, - ) - return True - - except Exception as e: - logger.error(f"Ошибка отправки уведомления об отсутствии подключения пользователю {user.telegram_id}: {e}") - return False - - async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool: - try: - texts = get_texts(user.language) - template = texts.get( - "SUBSCRIPTION_EXPIRED_1D", - ( - "⛔ Подписка закончилась\n\n" - "Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис." - ), - ) - message = template.format( - end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), - price=settings.format_price(settings.PRICE_30_DAYS), - ) - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")], - [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")], - [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], - ]) - - await self.bot.send_message( - user.telegram_id, - message, - parse_mode="HTML", - reply_markup=keyboard, - ) - return True - - except Exception as e: - logger.error(f"Ошибка отправки напоминания об истекшей подписке пользователю {user.telegram_id}: {e}") - return False - - async def _send_expired_discount_notification( - self, - user: User, - subscription: Subscription, - percent: int, - expires_at: datetime, - offer_id: int, - wave: str, - bonus_amount: int, - trigger_days: int = None, - ) -> bool: - try: - texts = get_texts(user.language) - - if wave == "second": - template = texts.get( - "SUBSCRIPTION_EXPIRED_SECOND_WAVE", - ( - "🔥 Скидка {percent}% на продление\n\n" - "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. " - "Предложение действует до {expires_at}." - ), - ) - else: - template = texts.get( - "SUBSCRIPTION_EXPIRED_THIRD_WAVE", - ( - "🎁 Индивидуальная скидка {percent}%\n\n" - "Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. " - "Скидка действует до {expires_at}." - ), - ) - - message = template.format( - percent=percent, - bonus=settings.format_price(bonus_amount), - expires_at=expires_at.strftime("%d.%m.%Y %H:%M"), - trigger_days=trigger_days or "", - ) - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🎁 Получить скидку", callback_data=f"claim_discount_{offer_id}")], - [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")], - [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")], - [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], - ]) - - await self.bot.send_message( - user.telegram_id, - message, - parse_mode="HTML", - reply_markup=keyboard, - ) - return True - - except Exception as e: - logger.error(f"Ошибка отправки скидочного уведомления пользователю {user.telegram_id}: {e}") - return False - + async def _send_autopay_success_notification(self, user: User, amount: int, days: int): try: texts = get_texts(user.language) diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py deleted file mode 100644 index a19edffd..00000000 --- a/app/services/notification_settings_service.py +++ /dev/null @@ -1,249 +0,0 @@ -import json -import json -import logging -from copy import deepcopy -from pathlib import Path -from typing import Any, Dict - -from app.config import settings - - -logger = logging.getLogger(__name__) - - -class NotificationSettingsService: - """Runtime-editable notification settings stored on disk.""" - - _storage_path: Path = Path("data/notification_settings.json") - _data: Dict[str, Dict[str, Any]] = {} - _loaded: bool = False - - _DEFAULTS: Dict[str, Dict[str, Any]] = { - "trial_inactive_1h": {"enabled": True}, - "trial_inactive_24h": {"enabled": True}, - "expired_1d": {"enabled": True}, - "expired_second_wave": { - "enabled": True, - "discount_percent": 10, - "valid_hours": 24, - }, - "expired_third_wave": { - "enabled": True, - "discount_percent": 20, - "valid_hours": 24, - "trigger_days": 5, - }, - } - - @classmethod - def _ensure_dir(cls) -> None: - try: - cls._storage_path.parent.mkdir(parents=True, exist_ok=True) - except Exception as exc: # pragma: no cover - filesystem guard - logger.error("Failed to create notification settings dir: %s", exc) - - @classmethod - def _load(cls) -> None: - if cls._loaded: - return - - cls._ensure_dir() - try: - if cls._storage_path.exists(): - raw = cls._storage_path.read_text(encoding="utf-8") - cls._data = json.loads(raw) if raw.strip() else {} - else: - cls._data = {} - except Exception as exc: - logger.error("Failed to load notification settings: %s", exc) - cls._data = {} - - changed = cls._apply_defaults() - if changed: - cls._save() - cls._loaded = True - - @classmethod - def _apply_defaults(cls) -> bool: - changed = False - for key, defaults in cls._DEFAULTS.items(): - current = cls._data.get(key) - if not isinstance(current, dict): - cls._data[key] = deepcopy(defaults) - changed = True - continue - - for def_key, def_value in defaults.items(): - if def_key not in current: - current[def_key] = def_value - changed = True - return changed - - @classmethod - def _save(cls) -> bool: - cls._ensure_dir() - try: - cls._storage_path.write_text( - json.dumps(cls._data, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - return True - except Exception as exc: - logger.error("Failed to save notification settings: %s", exc) - return False - - @classmethod - def _get(cls, key: str) -> Dict[str, Any]: - cls._load() - value = cls._data.get(key) - if not isinstance(value, dict): - value = deepcopy(cls._DEFAULTS.get(key, {})) - cls._data[key] = value - return value - - @classmethod - def get_config(cls) -> Dict[str, Dict[str, Any]]: - cls._load() - return deepcopy(cls._data) - - @classmethod - def _set_field(cls, key: str, field: str, value: Any) -> bool: - cls._load() - section = cls._get(key) - section[field] = value - cls._data[key] = section - return cls._save() - - @classmethod - def set_enabled(cls, key: str, enabled: bool) -> bool: - return cls._set_field(key, "enabled", bool(enabled)) - - @classmethod - def is_enabled(cls, key: str) -> bool: - return bool(cls._get(key).get("enabled", True)) - - # Trial inactivity helpers - @classmethod - def is_trial_inactive_1h_enabled(cls) -> bool: - return cls.is_enabled("trial_inactive_1h") - - @classmethod - def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("trial_inactive_1h", enabled) - - @classmethod - def is_trial_inactive_24h_enabled(cls) -> bool: - return cls.is_enabled("trial_inactive_24h") - - @classmethod - def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("trial_inactive_24h", enabled) - - # Expired subscription notifications - @classmethod - def is_expired_1d_enabled(cls) -> bool: - return cls.is_enabled("expired_1d") - - @classmethod - def set_expired_1d_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("expired_1d", enabled) - - @classmethod - def is_second_wave_enabled(cls) -> bool: - return cls.is_enabled("expired_second_wave") - - @classmethod - def set_second_wave_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("expired_second_wave", enabled) - - @classmethod - def get_second_wave_discount_percent(cls) -> int: - value = cls._get("expired_second_wave").get("discount_percent", 10) - try: - return max(0, min(100, int(value))) - except (TypeError, ValueError): - return 10 - - @classmethod - def set_second_wave_discount_percent(cls, percent: int) -> bool: - try: - percent_int = max(0, min(100, int(percent))) - except (TypeError, ValueError): - return False - return cls._set_field("expired_second_wave", "discount_percent", percent_int) - - @classmethod - def get_second_wave_valid_hours(cls) -> int: - value = cls._get("expired_second_wave").get("valid_hours", 24) - try: - return max(1, min(168, int(value))) - except (TypeError, ValueError): - return 24 - - @classmethod - def set_second_wave_valid_hours(cls, hours: int) -> bool: - try: - hours_int = max(1, min(168, int(hours))) - except (TypeError, ValueError): - return False - return cls._set_field("expired_second_wave", "valid_hours", hours_int) - - @classmethod - def is_third_wave_enabled(cls) -> bool: - return cls.is_enabled("expired_third_wave") - - @classmethod - def set_third_wave_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("expired_third_wave", enabled) - - @classmethod - def get_third_wave_discount_percent(cls) -> int: - value = cls._get("expired_third_wave").get("discount_percent", 20) - try: - return max(0, min(100, int(value))) - except (TypeError, ValueError): - return 20 - - @classmethod - def set_third_wave_discount_percent(cls, percent: int) -> bool: - try: - percent_int = max(0, min(100, int(percent))) - except (TypeError, ValueError): - return False - return cls._set_field("expired_third_wave", "discount_percent", percent_int) - - @classmethod - def get_third_wave_valid_hours(cls) -> int: - value = cls._get("expired_third_wave").get("valid_hours", 24) - try: - return max(1, min(168, int(value))) - except (TypeError, ValueError): - return 24 - - @classmethod - def set_third_wave_valid_hours(cls, hours: int) -> bool: - try: - hours_int = max(1, min(168, int(hours))) - except (TypeError, ValueError): - return False - return cls._set_field("expired_third_wave", "valid_hours", hours_int) - - @classmethod - def get_third_wave_trigger_days(cls) -> int: - value = cls._get("expired_third_wave").get("trigger_days", 5) - try: - return max(2, min(60, int(value))) - except (TypeError, ValueError): - return 5 - - @classmethod - def set_third_wave_trigger_days(cls, days: int) -> bool: - try: - days_int = max(2, min(60, int(days))) - except (TypeError, ValueError): - return False - return cls._set_field("expired_third_wave", "trigger_days", days_int) - - @classmethod - def are_notifications_globally_enabled(cls) -> bool: - return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True)) diff --git a/app/states.py b/app/states.py index 782fae7d..45e87e21 100644 --- a/app/states.py +++ b/app/states.py @@ -84,10 +84,9 @@ class AdminStates(StatesGroup): editing_device_price = State() editing_user_devices = State() editing_user_traffic = State() - + editing_rules_page = State() - editing_notification_value = State() - + confirming_sync = State() editing_server_name = State() diff --git a/locales/en.json b/locales/en.json index 046a5754..1b416564 100644 --- a/locales/en.json +++ b/locales/en.json @@ -484,23 +484,5 @@ "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", - "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!", - "TRIAL_INACTIVE_24H": "⏳ A full day passed without activity\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!", - "SUBSCRIPTION_EXPIRED_1D": "⛔ Your subscription expired\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}", - "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.", - "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.", - "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.", - "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.", - "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.", - "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.", - "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", - "NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.", - "NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.", - "NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):", - "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):", - "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):", - "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):", - "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):" + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance." } diff --git a/locales/ru.json b/locales/ru.json index db152690..736d38e1 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -484,23 +484,5 @@ "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", - "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!", - "TRIAL_INACTIVE_24H": "⏳ Прошли сутки с начала теста\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!", - "SUBSCRIPTION_EXPIRED_1D": "⛔ Подписка закончилась\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}", - "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.", - "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", - "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", - "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", - "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", - "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", - "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", - "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", - "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", - "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", - "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):", - "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):", - "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):", - "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):" + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку." } From ebf4c07142708ba255e601ccfa667a264912f5d2 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:15:17 +0300 Subject: [PATCH 034/146] Add notification scheduling and admin controls --- app/database/crud/subscription.py | 8 +- app/database/models.py | 9 +- app/database/universal_migration.py | 46 ++- app/handlers/admin/monitoring.py | 305 ++++++++++++++- app/keyboards/admin.py | 3 + app/services/monitoring_service.py | 350 +++++++++++++++++- app/services/notification_settings_service.py | 195 ++++++++++ app/services/remnawave_service.py | 61 ++- app/states.py | 6 + locales/en.json | 5 + locales/ru.json | 5 + 11 files changed, 970 insertions(+), 23 deletions(-) create mode 100644 app/services/notification_settings_service.py diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 051c2369..03bce858 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -965,7 +965,9 @@ async def create_subscription( device_limit: int = 1, connected_squads: list = None, remnawave_short_uuid: str = None, - subscription_url: str = "" + subscription_url: str = "", + first_connected_at: datetime | None = None, + last_connected_at: datetime | None = None, ) -> Subscription: if end_date is None: @@ -984,7 +986,9 @@ async def create_subscription( device_limit=device_limit, connected_squads=connected_squads, remnawave_short_uuid=remnawave_short_uuid, - subscription_url=subscription_url + subscription_url=subscription_url, + first_connected_at=first_connected_at, + last_connected_at=last_connected_at ) db.add(subscription) diff --git a/app/database/models.py b/app/database/models.py index f9b6d8ab..510181aa 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -415,12 +415,15 @@ class Subscription(Base): autopay_enabled = Column(Boolean, default=False) autopay_days_before = Column(Integer, default=3) - + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - + remnawave_short_uuid = Column(String(255), nullable=True) - + + first_connected_at = Column(DateTime, nullable=True) + last_connected_at = Column(DateTime, nullable=True) + user = relationship("User", back_populates="subscription") @property diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 40273ff4..dbc0dc3e 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1234,7 +1234,7 @@ async def fix_foreign_keys_for_user_deletion(): async def add_referral_system_columns(): logger.info("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===") - + try: async with engine.begin() as conn: db_type = await get_database_type() @@ -1282,6 +1282,38 @@ async def add_referral_system_columns(): logger.error(f"Ошибка миграции реферальной системы: {e}") return False + +async def add_subscription_connection_columns() -> bool: + logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ ПОДКЛЮЧЕНИЙ ПОДПИСОК ===") + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + for column_name in ("first_connected_at", "last_connected_at"): + column_exists = await check_column_exists("subscriptions", column_name) + if column_exists: + logger.info(f"Колонка {column_name} уже существует в subscriptions") + continue + + if db_type == "sqlite": + column_def = "TIMESTAMP" + elif db_type == "mysql": + column_def = "DATETIME" + else: + column_def = "TIMESTAMP" + + await conn.execute( + text(f"ALTER TABLE subscriptions ADD COLUMN {column_name} {column_def}") + ) + logger.info(f"Добавлена колонка {column_name} в subscriptions") + + return True + + except Exception as e: + logger.error(f"Ошибка добавления полей подключений подписок: {e}") + return False + async def create_subscription_conversions_table(): table_exists = await check_table_exists('subscription_conversions') if table_exists: @@ -1445,7 +1477,11 @@ async def run_universal_migration(): referral_migration_success = await add_referral_system_columns() if not referral_migration_success: logger.warning("⚠️ Проблемы с миграцией реферальной системы") - + + connections_added = await add_subscription_connection_columns() + if not connections_added: + logger.warning("⚠️ Проблемы с добавлением полей подключений подписок") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===") cryptobot_created = await create_cryptobot_payments_table() if cryptobot_created: @@ -1651,6 +1687,8 @@ async def check_migration_status(): "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, "users_auto_promo_group_assigned_column": False, + "subscriptions_first_connected_column": False, + "subscriptions_last_connected_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1666,6 +1704,8 @@ async def check_migration_status(): status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') + status["subscriptions_first_connected_column"] = await check_column_exists('subscriptions', 'first_connected_at') + status["subscriptions_last_connected_column"] = await check_column_exists('subscriptions', 'last_connected_at') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -1701,6 +1741,8 @@ async def check_migration_status(): "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", + "subscriptions_first_connected_column": "Колонка first_connected_at у подписок", + "subscriptions_last_connected_column": "Колонка last_connected_at у подписок", } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index be876876..a6dbc9f1 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -2,21 +2,123 @@ import asyncio import logging from datetime import datetime, timedelta from aiogram import Router, F -from aiogram.types import Message, CallbackQuery +from aiogram.fsm.context import FSMContext +from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton from aiogram.filters import Command from app.config import settings from app.database.database import get_db from app.services.monitoring_service import monitoring_service +from app.services.notification_settings_service import AutoNotificationSettingsService from app.utils.decorators import admin_required from app.utils.pagination import paginate_list from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard from app.localization.texts import get_texts +from app.states import MonitoringNotificationStates logger = logging.getLogger(__name__) router = Router() +def _toggle_text(enabled: bool) -> str: + return "✅ Вкл" if enabled else "❌ Выкл" + + +def _build_notification_settings(language: str) -> tuple[str, InlineKeyboardMarkup]: + texts = get_texts(language) + + trial_1h_enabled = AutoNotificationSettingsService.is_trial_1h_enabled() + trial_24h_enabled = AutoNotificationSettingsService.is_trial_24h_enabled() + expired_day1_enabled = AutoNotificationSettingsService.is_expired_day1_enabled() + + expired_day23_enabled = AutoNotificationSettingsService.is_expired_day23_enabled() + day23_discount = AutoNotificationSettingsService.get_expired_day23_discount() + day23_valid = AutoNotificationSettingsService.get_expired_day23_valid_hours() + window_start, window_end = AutoNotificationSettingsService.get_expired_day23_window() + + expired_dayN_enabled = AutoNotificationSettingsService.is_expired_dayN_enabled() + dayN_discount = AutoNotificationSettingsService.get_expired_dayN_discount() + dayN_valid = AutoNotificationSettingsService.get_expired_dayN_valid_hours() + dayN_threshold = AutoNotificationSettingsService.get_expired_dayN_threshold() + + overview_lines = [ + f"⏱️ Триал +1 час — {'вкл' if trial_1h_enabled else 'выкл'}", + f"🕛 Триал +24 часа — {'вкл' if trial_24h_enabled else 'выкл'}", + f"📆 Истёкшая подписка (1 сутки) — {'вкл' if expired_day1_enabled else 'выкл'}", + ( + f"🎯 {window_start}-{window_end} дней без продления — " + f"{'вкл' if expired_day23_enabled else 'выкл'} • скидка {day23_discount}% на {day23_valid} ч" + ), + ( + f"🔥 ≥{dayN_threshold} дней без продления — " + f"{'вкл' if expired_dayN_enabled else 'выкл'} • скидка {dayN_discount}% на {dayN_valid} ч" + ), + ] + + text = ( + "🔔 Автоуведомления\n\n" + "Управляйте напоминаниями о подключении и продлении подписки.\n\n" + + "\n".join(overview_lines) + + "\n\nИспользуйте кнопки ниже для включения, отключения или изменения параметров." + ) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=f"⏱️ Триал +1 час: {_toggle_text(trial_1h_enabled)}", + callback_data="admin_mon_notif_toggle_trial_1h", + ) + ], + [ + InlineKeyboardButton( + text=f"🕛 Триал +24 часа: {_toggle_text(trial_24h_enabled)}", + callback_data="admin_mon_notif_toggle_trial_24h", + ) + ], + [ + InlineKeyboardButton( + text=f"📆 1 сутки после окончания: {_toggle_text(expired_day1_enabled)}", + callback_data="admin_mon_notif_toggle_expired_day1", + ) + ], + [ + InlineKeyboardButton( + text=( + f"🎯 {window_start}-{window_end} дн.: " + f"{_toggle_text(expired_day23_enabled)} ({day23_discount}%)" + ), + callback_data="admin_mon_notif_toggle_expired_day23", + ), + InlineKeyboardButton( + text="✏️ %", + callback_data="admin_mon_notif_set_day23_discount", + ), + ], + [ + InlineKeyboardButton( + text=( + f"🔥 ≥{dayN_threshold} дн.: " + f"{_toggle_text(expired_dayN_enabled)} ({dayN_discount}%)" + ), + callback_data="admin_mon_notif_toggle_expired_dayN", + ), + InlineKeyboardButton( + text="✏️ %", + callback_data="admin_mon_notif_set_dayN_discount", + ), + InlineKeyboardButton( + text="✏️ N", + callback_data="admin_mon_notif_set_dayN_threshold", + ), + ], + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_monitoring")], + ] + ) + + return text, keyboard + + @router.callback_query(F.data == "admin_monitoring") @admin_required async def admin_monitoring_menu(callback: CallbackQuery): @@ -52,6 +154,207 @@ async def admin_monitoring_menu(callback: CallbackQuery): await callback.answer("❌ Ошибка получения данных", show_alert=True) +@router.callback_query(F.data == "admin_mon_notifications") +@admin_required +async def monitoring_notifications_menu(callback: CallbackQuery, state: FSMContext): + try: + await state.clear() + language = callback.from_user.language_code or "ru" + text, keyboard = _build_notification_settings(language) + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + await callback.answer() + except Exception as e: + logger.error(f"Ошибка отображения настроек уведомлений: {e}") + await callback.answer("❌ Ошибка", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notif_toggle_trial_1h") +@admin_required +async def toggle_trial_1h_reminder(callback: CallbackQuery, state: FSMContext): + AutoNotificationSettingsService.set_trial_1h_enabled( + not AutoNotificationSettingsService.is_trial_1h_enabled() + ) + await monitoring_notifications_menu(callback, state) + + +@router.callback_query(F.data == "admin_mon_notif_toggle_trial_24h") +@admin_required +async def toggle_trial_24h_reminder(callback: CallbackQuery, state: FSMContext): + AutoNotificationSettingsService.set_trial_24h_enabled( + not AutoNotificationSettingsService.is_trial_24h_enabled() + ) + await monitoring_notifications_menu(callback, state) + + +@router.callback_query(F.data == "admin_mon_notif_toggle_expired_day1") +@admin_required +async def toggle_expired_day1(callback: CallbackQuery, state: FSMContext): + AutoNotificationSettingsService.set_expired_day1_enabled( + not AutoNotificationSettingsService.is_expired_day1_enabled() + ) + await monitoring_notifications_menu(callback, state) + + +@router.callback_query(F.data == "admin_mon_notif_toggle_expired_day23") +@admin_required +async def toggle_expired_day23(callback: CallbackQuery, state: FSMContext): + AutoNotificationSettingsService.set_expired_day23_enabled( + not AutoNotificationSettingsService.is_expired_day23_enabled() + ) + await monitoring_notifications_menu(callback, state) + + +@router.callback_query(F.data == "admin_mon_notif_toggle_expired_dayN") +@admin_required +async def toggle_expired_dayN(callback: CallbackQuery, state: FSMContext): + AutoNotificationSettingsService.set_expired_dayN_enabled( + not AutoNotificationSettingsService.is_expired_dayN_enabled() + ) + await monitoring_notifications_menu(callback, state) + + +@router.callback_query(F.data == "admin_mon_notif_set_day23_discount") +@admin_required +async def start_set_day23_discount(callback: CallbackQuery, state: FSMContext): + try: + await state.set_state(MonitoringNotificationStates.waiting_for_day23_discount) + language = callback.from_user.language_code or "ru" + texts = get_texts(language) + current = AutoNotificationSettingsService.get_expired_day23_discount() + prompt = ( + "🎯 Скидка на 2-3 сутки\n\n" + "Введите размер скидки в процентах (0-100), которая будет доступна в течение 24 часов.\n\n" + f"Текущее значение: {current}%" + ) + back_keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data="admin_mon_notifications")]] + ) + await callback.message.edit_text(prompt, parse_mode="HTML", reply_markup=back_keyboard) + await callback.answer() + except Exception as e: + logger.error(f"Ошибка запроса скидки для 2-3 суток: {e}") + await callback.answer("❌ Ошибка", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notif_set_dayN_discount") +@admin_required +async def start_set_dayN_discount(callback: CallbackQuery, state: FSMContext): + try: + await state.set_state(MonitoringNotificationStates.waiting_for_dayN_discount) + language = callback.from_user.language_code or "ru" + texts = get_texts(language) + current = AutoNotificationSettingsService.get_expired_dayN_discount() + prompt = ( + "🔥 Большая скидка\n\n" + "Введите размер скидки (0-100), которая будет предложена спустя N суток после окончания подписки.\n\n" + f"Текущее значение: {current}%" + ) + back_keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data="admin_mon_notifications")]] + ) + await callback.message.edit_text(prompt, parse_mode="HTML", reply_markup=back_keyboard) + await callback.answer() + except Exception as e: + logger.error(f"Ошибка запроса большой скидки: {e}") + await callback.answer("❌ Ошибка", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notif_set_dayN_threshold") +@admin_required +async def start_set_dayN_threshold(callback: CallbackQuery, state: FSMContext): + try: + await state.set_state(MonitoringNotificationStates.waiting_for_dayN_threshold) + language = callback.from_user.language_code or "ru" + texts = get_texts(language) + current = AutoNotificationSettingsService.get_expired_dayN_threshold() + prompt = ( + "📅 Порог для большой скидки\n\n" + "Введите через сколько суток после окончания подписки предлагать вторую скидку.\n" + "Рекомендуем значение не меньше 4, чтобы не пересекаться с предыдущими уведомлениями.\n\n" + f"Текущее значение: {current}" + ) + back_keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data="admin_mon_notifications")]] + ) + await callback.message.edit_text(prompt, parse_mode="HTML", reply_markup=back_keyboard) + await callback.answer() + except Exception as e: + logger.error(f"Ошибка запроса порога для большой скидки: {e}") + await callback.answer("❌ Ошибка", show_alert=True) + +@router.message(MonitoringNotificationStates.waiting_for_day23_discount) +@admin_required +async def handle_day23_discount(message: Message, state: FSMContext): + value_raw = (message.text or "").strip() + try: + value = int(value_raw) + except ValueError: + await message.answer("❌ Введите целое число от 0 до 100") + return + + if value < 0 or value > 100: + await message.answer("❌ Допустимый диапазон скидки: 0-100") + return + + AutoNotificationSettingsService.set_expired_day23_discount(value) + await state.clear() + texts = get_texts(message.from_user.language_code or "ru") + keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data="admin_mon_notifications")]] + ) + await message.answer(f"✅ Скидка установлена на {value}%", reply_markup=keyboard) + + +@router.message(MonitoringNotificationStates.waiting_for_dayN_discount) +@admin_required +async def handle_dayN_discount(message: Message, state: FSMContext): + value_raw = (message.text or "").strip() + try: + value = int(value_raw) + except ValueError: + await message.answer("❌ Введите целое число от 0 до 100") + return + + if value < 0 or value > 100: + await message.answer("❌ Допустимый диапазон скидки: 0-100") + return + + AutoNotificationSettingsService.set_expired_dayN_discount(value) + await state.clear() + texts = get_texts(message.from_user.language_code or "ru") + keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data="admin_mon_notifications")]] + ) + await message.answer(f"✅ Скидка установлена на {value}%", reply_markup=keyboard) + + +@router.message(MonitoringNotificationStates.waiting_for_dayN_threshold) +@admin_required +async def handle_dayN_threshold(message: Message, state: FSMContext): + value_raw = (message.text or "").strip() + try: + days = int(value_raw) + except ValueError: + await message.answer("❌ Введите целое число (минимум 4)") + return + + if days < 4: + await message.answer("❌ Минимальное значение — 4") + return + + if days > 60: + await message.answer("❌ Максимальное значение — 60 суток") + return + + AutoNotificationSettingsService.set_expired_dayN_threshold(days) + await state.clear() + texts = get_texts(message.from_user.language_code or "ru") + keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data="admin_mon_notifications")]] + ) + await message.answer(f"✅ Порог установлен на {days} суток", reply_markup=keyboard) + + @router.callback_query(F.data == "admin_mon_start") @admin_required async def start_monitoring_callback(callback: CallbackQuery): diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 8219147b..7921fc99 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -778,6 +778,9 @@ def get_monitoring_keyboard() -> InlineKeyboardMarkup: InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="admin_mon_force_check"), InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs") ], + [ + InlineKeyboardButton(text="🔔 Уведомления", callback_data="admin_mon_notifications") + ], [ InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"), InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics") diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index a190aec4..2afd2801 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -23,6 +23,7 @@ from app.database.crud.notification import ( ) from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus from app.services.subscription_service import SubscriptionService +from app.services.notification_settings_service import AutoNotificationSettingsService from app.services.payment_service import PaymentService from app.localization.texts import get_texts @@ -82,8 +83,10 @@ class MonitoringService: await self._cleanup_notification_cache() await self._check_expired_subscriptions(db) + await self._check_expired_followups(db) await self._check_expiring_subscriptions(db) - await self._check_trial_expiring_soon(db) + await self._check_trial_expiring_soon(db) + await self._check_trial_connection_reminders(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) await self._sync_with_remnawave(db) @@ -117,7 +120,7 @@ class MonitoringService: async def _check_expired_subscriptions(self, db: AsyncSession): try: expired_subscriptions = await get_expired_subscriptions(db) - + for subscription in expired_subscriptions: from app.database.crud.subscription import expire_subscription await expire_subscription(db, subscription) @@ -141,6 +144,134 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки истёкших подписок: {e}") + async def _check_expired_followups(self, db: AsyncSession): + if not settings.ENABLE_NOTIFICATIONS or not self.bot: + return + + try: + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.status == SubscriptionStatus.EXPIRED.value, + Subscription.is_trial == False, + Subscription.end_date.isnot(None), + ) + ) + ) + subscriptions = result.scalars().all() + if not subscriptions: + return + + now = datetime.utcnow() + + day1_enabled = AutoNotificationSettingsService.is_expired_day1_enabled() + day23_enabled = AutoNotificationSettingsService.is_expired_day23_enabled() + dayN_enabled = AutoNotificationSettingsService.is_expired_dayN_enabled() + + day23_discount = AutoNotificationSettingsService.get_expired_day23_discount() + day23_valid = AutoNotificationSettingsService.get_expired_day23_valid_hours() + window_start, window_end = AutoNotificationSettingsService.get_expired_day23_window() + + dayN_threshold = AutoNotificationSettingsService.get_expired_dayN_threshold() + dayN_discount = AutoNotificationSettingsService.get_expired_dayN_discount() + dayN_valid = AutoNotificationSettingsService.get_expired_dayN_valid_hours() + + counters = {"day1": 0, "day23": 0, "dayN": 0} + + for subscription in subscriptions: + user = subscription.user + if not user or not subscription.end_date: + continue + + elapsed = now - subscription.end_date + if elapsed.total_seconds() < 0: + continue + + elapsed_days = elapsed.total_seconds() / 86400 + days_since = max(1, int(elapsed_days)) + + if dayN_enabled and elapsed_days >= dayN_threshold: + if not await notification_sent(db, user.id, subscription.id, "expired_discount_dayN"): + sent = await self._send_expired_followup_notification( + user, + "dayN", + discount_percent=dayN_discount, + valid_hours=dayN_valid, + days_since=days_since, + threshold=dayN_threshold, + ) + if sent: + await record_notification(db, user.id, subscription.id, "expired_discount_dayN") + counters["dayN"] += 1 + continue + + if ( + day23_enabled + and elapsed_days >= window_start + and elapsed_days < (window_end + 1) + ): + if not await notification_sent(db, user.id, subscription.id, "expired_discount_day23"): + sent = await self._send_expired_followup_notification( + user, + "day23", + discount_percent=day23_discount, + valid_hours=day23_valid, + days_since=days_since, + ) + if sent: + await record_notification(db, user.id, subscription.id, "expired_discount_day23") + counters["day23"] += 1 + continue + + if day1_enabled and 1 <= elapsed_days < 2: + if not await notification_sent(db, user.id, subscription.id, "expired_followup_day1"): + sent = await self._send_expired_followup_notification( + user, + "day1", + days_since=days_since, + ) + if sent: + await record_notification(db, user.id, subscription.id, "expired_followup_day1") + counters["day1"] += 1 + + if counters["day1"]: + await self._log_monitoring_event( + db, + "expired_followup_day1_sent", + f"Отправлено {counters['day1']} напоминаний через 1 сутки", + {"count": counters["day1"]}, + ) + + if counters["day23"]: + await self._log_monitoring_event( + db, + "expired_followup_day23_sent", + f"Отправлено {counters['day23']} предложений со скидкой {day23_discount}%", + { + "count": counters["day23"], + "discount_percent": day23_discount, + "valid_hours": day23_valid, + }, + ) + + if counters["dayN"]: + await self._log_monitoring_event( + db, + "expired_followup_dayN_sent", + f"Отправлено {counters['dayN']} предложений со скидкой {dayN_discount}%", + { + "count": counters["dayN"], + "discount_percent": dayN_discount, + "valid_hours": dayN_valid, + "threshold_days": dayN_threshold, + }, + ) + + except Exception as e: + logger.error(f"Ошибка проверки последующих уведомлений по истекшим подпискам: {e}") + async def update_remnawave_user( self, db: AsyncSession, @@ -250,7 +381,7 @@ class MonitoringService: async def _check_trial_expiring_soon(self, db: AsyncSession): try: threshold_time = datetime.utcnow() + timedelta(hours=2) - + result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -288,11 +419,95 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}") - + + async def _check_trial_connection_reminders(self, db: AsyncSession): + if not settings.ENABLE_NOTIFICATIONS or not self.bot: + return + + thresholds: list[tuple[str, timedelta]] = [] + if AutoNotificationSettingsService.is_trial_1h_enabled(): + thresholds.append(("trial_no_connection_1h", timedelta(hours=1))) + if AutoNotificationSettingsService.is_trial_24h_enabled(): + thresholds.append(("trial_no_connection_24h", timedelta(hours=24))) + + if not thresholds: + return + + try: + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.is_trial == True, + Subscription.start_date.isnot(None), + ) + ) + ) + subscriptions = result.scalars().all() + if not subscriptions: + return + + now = datetime.utcnow() + sent_counts = {key: 0 for key, _ in thresholds} + + for subscription in subscriptions: + user = subscription.user + if not user or not subscription.start_date: + continue + + if subscription.end_date and subscription.end_date <= now: + continue + + has_connected = bool( + (subscription.first_connected_at) + or (subscription.last_connected_at) + or (subscription.traffic_used_gb or 0) > 0.01 + ) + if has_connected: + continue + + elapsed = now - subscription.start_date + if elapsed.total_seconds() <= 0: + continue + + for notification_type, delta in thresholds: + hours = int(delta.total_seconds() // 3600) + + if notification_type == "trial_no_connection_1h" and elapsed >= timedelta(hours=24): + continue + + if elapsed >= delta: + if await notification_sent(db, user.id, subscription.id, notification_type): + continue + + sent = await self._send_trial_no_connection_notification(user, hours) + if sent: + await record_notification(db, user.id, subscription.id, notification_type) + sent_counts[notification_type] += 1 + + log_labels = { + "trial_no_connection_1h": "о подключении через 1 час", + "trial_no_connection_24h": "о подключении через 24 часа", + } + + for notif_type, count in sent_counts.items(): + if count > 0: + await self._log_monitoring_event( + db, + f"{notif_type}_sent", + f"Отправлено {count} напоминаний {log_labels.get(notif_type, notif_type)}", + {"count": count}, + ) + + except Exception as e: + logger.error(f"Ошибка проверки напоминаний о подключении триала: {e}") + async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]: current_time = datetime.utcnow() threshold_date = current_time + timedelta(days=days_before) - + result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -417,11 +632,83 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}") return False - + + async def _send_expired_followup_notification( + self, + user: User, + variant: str, + *, + discount_percent: int = 0, + valid_hours: int = 24, + days_since: int = 1, + threshold: int = 0, + ) -> bool: + try: + texts = get_texts(user.language) + + if variant == "day1": + message = texts.t( + "SUBSCRIPTION_EXPIRED_DAY1", + """⛔ Подписка закончилась + +Прошли {days} сутки без продления. Доступ к серверам закрыт, но вы можете восстановить его в один клик. + +Нажмите "Купить подписку" или напишите в поддержку, если нужна помощь.""", + ).format(days=days_since) + elif variant == "day23": + message = texts.t( + "SUBSCRIPTION_EXPIRED_DAY23", + """🎯 Скидка {discount}% на продление + +Подписка завершилась {days} дня назад. Воспользуйтесь временной скидкой {discount}% — предложение действует ещё {valid_hours} часов. + +Продлите подписку сейчас и вернём доступ моментально.""", + ).format(discount=discount_percent, valid_hours=valid_hours, days=days_since) + elif variant == "dayN": + message = texts.t( + "SUBSCRIPTION_EXPIRED_DAYN", + """🔥 Возвращайтесь со скидкой {discount}% + +Прошло уже {days} суток без VPN. Для вас действует расширенная скидка {discount}% на ближайшие {valid_hours} часов. + +Нажмите "Купить подписку" — доступ восстановится сразу после оплаты.""", + ).format( + discount=discount_percent, + valid_hours=valid_hours, + days=days_since, + threshold=threshold, + ) + else: + return False + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy")], + [InlineKeyboardButton(text=texts.BALANCE_TOP_UP, callback_data="balance_topup")], + [InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")], + ] + ) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error( + f"Ошибка отправки follow-up уведомления пользователю {user.telegram_id}: {e}" + ) + return False + async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool: try: from app.utils.formatters import format_days_declension - + texts = get_texts(user.language) days_text = format_days_declension(days, user.language) @@ -462,10 +749,57 @@ class MonitoringService: logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}") return False + async def _send_trial_no_connection_notification(self, user: User, hours: int) -> bool: + try: + texts = get_texts(user.language) + + if hours <= 1: + message = texts.t( + "TRIAL_NO_CONNECTION_1H", + """⏳ Вы ещё не подключились к VPN + +Прошел {hours} час после активации тестовой подписки, но мы не видим подключений. + +Нажмите «Подключиться», чтобы открыть инструкцию, или загляните в поддержку — поможем настроить всё за пару минут.""", + ).format(hours=hours) + else: + message = texts.t( + "TRIAL_NO_CONNECTION_24H", + """⌛️ Тест ещё не использован + +Прошли {hours} часа после активации тестовой подписки, и подключений всё ещё нет. + +Вернитесь в раздел «Подписка», нажмите «Подключиться» и следуйте инструкции. Если что-то не получается — поддержка всегда рядом.""", + ).format(hours=hours) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], + [InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")], + ] + ) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error( + f"Ошибка отправки напоминания о подключении триала пользователю {user.telegram_id}: {e}" + ) + return False + async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool: try: texts = get_texts(user.language) - + message = f""" 🎁 Тестовая подписка скоро закончится! diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py new file mode 100644 index 00000000..43b7896a --- /dev/null +++ b/app/services/notification_settings_service.py @@ -0,0 +1,195 @@ +import json +import logging +from pathlib import Path +from typing import Any, Dict + + +logger = logging.getLogger(__name__) + + +class AutoNotificationSettingsService: + """Runtime storage for auto notification settings. + + Settings are stored in a JSON file inside the data directory so they can be + tweaked from the admin panel without restarting the bot. Only overrides are + persisted – sensible defaults are provided in code. + """ + + _storage_path: Path = Path("data/auto_notification_settings.json") + _defaults: Dict[str, Any] = { + "trial_no_connection_1h_enabled": True, + "trial_no_connection_24h_enabled": True, + "expired_day1_enabled": True, + "expired_day23_enabled": True, + "expired_day23_discount_percent": 15, + "expired_day23_valid_hours": 24, + "expired_day23_window_start": 2, + "expired_day23_window_end": 3, + "expired_dayN_enabled": True, + "expired_dayN_discount_percent": 25, + "expired_dayN_valid_hours": 24, + "expired_dayN_threshold": 7, + } + _data: Dict[str, Any] = {} + _loaded: bool = False + + @classmethod + def _ensure_loaded(cls) -> None: + if cls._loaded: + return + + try: + cls._storage_path.parent.mkdir(parents=True, exist_ok=True) + except Exception as e: + logger.error("Не удалось создать директорию настроек уведомлений: %s", e) + + if cls._storage_path.exists(): + try: + raw = cls._storage_path.read_text(encoding="utf-8") + data = json.loads(raw) if raw.strip() else {} + if isinstance(data, dict): + cls._data = data + else: + cls._data = {} + except Exception as e: + logger.error("Ошибка загрузки настроек уведомлений: %s", e) + cls._data = {} + else: + cls._data = {} + + cls._loaded = True + + @classmethod + def _get_merged(cls) -> Dict[str, Any]: + cls._ensure_loaded() + merged = dict(cls._defaults) + merged.update(cls._data) + return merged + + @classmethod + def _save(cls) -> bool: + try: + cls._storage_path.parent.mkdir(parents=True, exist_ok=True) + data_to_save = cls._get_merged() + cls._storage_path.write_text( + json.dumps(data_to_save, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return True + except Exception as e: + logger.error("Ошибка сохранения настроек уведомлений: %s", e) + return False + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """Returns a copy of current settings with defaults applied.""" + + return cls._get_merged().copy() + + @classmethod + def _set_value(cls, key: str, value: Any) -> bool: + cls._ensure_loaded() + cls._data[key] = value + return cls._save() + + # Trial reminders + @classmethod + def is_trial_1h_enabled(cls) -> bool: + return bool(cls.get_settings().get("trial_no_connection_1h_enabled", True)) + + @classmethod + def set_trial_1h_enabled(cls, enabled: bool) -> bool: + return cls._set_value("trial_no_connection_1h_enabled", bool(enabled)) + + @classmethod + def is_trial_24h_enabled(cls) -> bool: + return bool(cls.get_settings().get("trial_no_connection_24h_enabled", True)) + + @classmethod + def set_trial_24h_enabled(cls, enabled: bool) -> bool: + return cls._set_value("trial_no_connection_24h_enabled", bool(enabled)) + + # Expired subscription follow-ups + @classmethod + def is_expired_day1_enabled(cls) -> bool: + return bool(cls.get_settings().get("expired_day1_enabled", True)) + + @classmethod + def set_expired_day1_enabled(cls, enabled: bool) -> bool: + return cls._set_value("expired_day1_enabled", bool(enabled)) + + @classmethod + def is_expired_day23_enabled(cls) -> bool: + return bool(cls.get_settings().get("expired_day23_enabled", True)) + + @classmethod + def set_expired_day23_enabled(cls, enabled: bool) -> bool: + return cls._set_value("expired_day23_enabled", bool(enabled)) + + @classmethod + def get_expired_day23_discount(cls) -> int: + try: + return int(cls.get_settings().get("expired_day23_discount_percent", 15)) + except Exception: + return 15 + + @classmethod + def set_expired_day23_discount(cls, percent: int) -> bool: + return cls._set_value("expired_day23_discount_percent", int(percent)) + + @classmethod + def get_expired_day23_valid_hours(cls) -> int: + try: + return int(cls.get_settings().get("expired_day23_valid_hours", 24)) + except Exception: + return 24 + + @classmethod + def get_expired_day23_window(cls) -> tuple[int, int]: + settings = cls.get_settings() + try: + start = int(settings.get("expired_day23_window_start", 2)) + except Exception: + start = 2 + try: + end = int(settings.get("expired_day23_window_end", 3)) + except Exception: + end = 3 + return start, end + + @classmethod + def is_expired_dayN_enabled(cls) -> bool: + return bool(cls.get_settings().get("expired_dayN_enabled", True)) + + @classmethod + def set_expired_dayN_enabled(cls, enabled: bool) -> bool: + return cls._set_value("expired_dayN_enabled", bool(enabled)) + + @classmethod + def get_expired_dayN_discount(cls) -> int: + try: + return int(cls.get_settings().get("expired_dayN_discount_percent", 25)) + except Exception: + return 25 + + @classmethod + def set_expired_dayN_discount(cls, percent: int) -> bool: + return cls._set_value("expired_dayN_discount_percent", int(percent)) + + @classmethod + def get_expired_dayN_valid_hours(cls) -> int: + try: + return int(cls.get_settings().get("expired_dayN_valid_hours", 24)) + except Exception: + return 24 + + @classmethod + def get_expired_dayN_threshold(cls) -> int: + try: + return int(cls.get_settings().get("expired_dayN_threshold", 7)) + except Exception: + return 7 + + @classmethod + def set_expired_dayN_threshold(cls, days: int) -> bool: + return cls._set_value("expired_dayN_threshold", int(days)) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index dd6ee50c..403ba0ad 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -35,7 +35,7 @@ class RemnaWaveService: def _parse_remnawave_date(self, date_str: str) -> datetime: if not date_str: return datetime.utcnow() + timedelta(days=30) - + try: cleaned_date = date_str.strip() @@ -59,7 +59,33 @@ class RemnaWaveService: except Exception as e: logger.warning(f"⚠️ Не удалось распарсить дату '{date_str}': {e}. Используем дефолтную дату.") return datetime.utcnow() + timedelta(days=30) - + + def _parse_optional_remnawave_date(self, date_str: Optional[str]) -> Optional[datetime]: + if not date_str: + return None + + try: + cleaned_date = date_str.strip() + + if cleaned_date.endswith('Z'): + cleaned_date = cleaned_date[:-1] + '+00:00' + + if '+00:00+00:00' in cleaned_date: + cleaned_date = cleaned_date.replace('+00:00+00:00', '+00:00') + + cleaned_date = re.sub(r'(\+\d{2}:\d{2})\+\d{2}:\d{2}$', r'\1', cleaned_date) + + parsed_date = datetime.fromisoformat(cleaned_date) + + if parsed_date.tzinfo is not None: + parsed_date = parsed_date.replace(tzinfo=None) + + return parsed_date + + except Exception as e: + logger.debug(f"Не удалось распарсить дату '{date_str}': {e}") + return None + async def get_system_statistics(self) -> Dict[str, Any]: try: async with self.api as api: @@ -624,7 +650,7 @@ class RemnaWaveService: used_traffic_bytes = panel_user.get('usedTrafficBytes', 0) traffic_used_gb = used_traffic_bytes / (1024**3) - + active_squads = panel_user.get('activeInternalSquads', []) squad_uuids = [] if isinstance(active_squads, list): @@ -634,17 +660,25 @@ class RemnaWaveService: elif isinstance(squad, str): squad_uuids.append(squad) + first_connected_at = self._parse_optional_remnawave_date(panel_user.get('firstConnectedAt')) + last_connected_at = ( + self._parse_optional_remnawave_date(panel_user.get('onlineAt')) + or self._parse_optional_remnawave_date(panel_user.get('subLastOpenedAt')) + ) + subscription_data = { 'user_id': user.id, 'status': status.value, - 'is_trial': False, + 'is_trial': False, 'end_date': expire_at, 'traffic_limit_gb': traffic_limit_gb, 'traffic_used_gb': traffic_used_gb, 'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1, 'connected_squads': squad_uuids, 'remnawave_short_uuid': panel_user.get('shortUuid'), - 'subscription_url': panel_user.get('subscriptionUrl', '') + 'subscription_url': panel_user.get('subscriptionUrl', ''), + 'first_connected_at': first_connected_at, + 'last_connected_at': last_connected_at, } subscription = await create_subscription(db, **subscription_data) @@ -726,13 +760,26 @@ class RemnaWaveService: if subscription.device_limit != device_limit: subscription.device_limit = device_limit logger.debug(f"Обновлен лимит устройств: {device_limit}") - + if not subscription.remnawave_short_uuid: subscription.remnawave_short_uuid = panel_user.get('shortUuid') - + panel_url = panel_user.get('subscriptionUrl', '') if not subscription.subscription_url or subscription.subscription_url != panel_url: subscription.subscription_url = panel_url + + first_connected_at = self._parse_optional_remnawave_date(panel_user.get('firstConnectedAt')) + if first_connected_at and subscription.first_connected_at != first_connected_at: + subscription.first_connected_at = first_connected_at + logger.debug(f"Обновлено первое подключение: {first_connected_at}") + + last_connected_at = ( + self._parse_optional_remnawave_date(panel_user.get('onlineAt')) + or self._parse_optional_remnawave_date(panel_user.get('subLastOpenedAt')) + ) + if last_connected_at and subscription.last_connected_at != last_connected_at: + subscription.last_connected_at = last_connected_at + logger.debug(f"Обновлено последнее подключение: {last_connected_at}") active_squads = panel_user.get('activeInternalSquads', []) squad_uuids = [] diff --git a/app/states.py b/app/states.py index 45e87e21..aec39152 100644 --- a/app/states.py +++ b/app/states.py @@ -138,3 +138,9 @@ class AdminSubmenuStates(StatesGroup): in_communications_submenu = State() in_settings_submenu = State() in_system_submenu = State() + + +class MonitoringNotificationStates(StatesGroup): + waiting_for_day23_discount = State() + waiting_for_dayN_discount = State() + waiting_for_dayN_threshold = State() diff --git a/locales/en.json b/locales/en.json index 1b416564..e568fe18 100644 --- a/locales/en.json +++ b/locales/en.json @@ -48,6 +48,11 @@ "MENU_BALANCE": "💰 Balance", "MENU_SUBSCRIPTION": "📱 Subscription", "MENU_TRIAL": "🎁 Trial subscription", + "TRIAL_NO_CONNECTION_1H": "⏳ You haven't connected yet\n\nIt's been {hours} hour since you activated the trial, but we still haven't seen any connections.\n\nTap “Connect” to open the setup guide or contact support — we'll help you in minutes.", + "TRIAL_NO_CONNECTION_24H": "⌛️ Your trial is still unused\n\n{hours} hours have passed since activation and no connections were detected.\n\nOpen the “Subscription” section, press “Connect” and follow the instructions. If you need help, support is a tap away.", + "SUBSCRIPTION_EXPIRED_DAY1": "⛔ Your subscription expired\n\nIt's been {days} day(s) without renewal. Server access is disabled until you renew the subscription.", + "SUBSCRIPTION_EXPIRED_DAY23": "🎯 {discount}% off renewal\n\nYour subscription ended {days} day(s) ago. Use the {discount}% discount — it stays active for the next {valid_hours} hours.", + "SUBSCRIPTION_EXPIRED_DAYN": "🔥 Come back with {discount}% off\n\nIt's been {days} day(s) without VPN. A {discount}% discount is active for the next {valid_hours} hours just for you.", "MY_BALANCE_BUTTON": "💰 My balance", "MY_SUBSCRIPTION_BUTTON": "📱 My subscription", "NO": "❌ No", diff --git a/locales/ru.json b/locales/ru.json index 736d38e1..ea2dcdc5 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -225,6 +225,11 @@ "MENU_SUBSCRIPTION": "📱 Подписка", "MENU_SUPPORT": "🛠️ Техподдержка", "MENU_TRIAL": "🧪 Тестовая подписка", + "TRIAL_NO_CONNECTION_1H": "⏳ Вы ещё не подключились к VPN\n\nПрошел {hours} час после активации тестовой подписки, но мы не фиксируем подключений.\n\nНажмите «Подключиться», чтобы открыть инструкцию, или напишите в поддержку — поможем настроить всё за пару минут.", + "TRIAL_NO_CONNECTION_24H": "⌛️ Тест ещё не использован\n\nПрошли {hours} часа после активации тестовой подписки, и подключений всё ещё нет.\n\nВернитесь в раздел «Подписка», нажмите «Подключиться» и следуйте инструкции. Если что-то не получается — поддержка всегда рядом.", + "SUBSCRIPTION_EXPIRED_DAY1": "⛔ Подписка закончилась\n\nПрошли {days} сутки без продления, доступ к серверам заблокирован. Продлите подписку, чтобы мгновенно вернуть доступ.", + "SUBSCRIPTION_EXPIRED_DAY23": "🎯 Скидка {discount}% на продление\n\nПодписка завершилась {days} дня назад. Воспользуйтесь персональной скидкой {discount}% — предложение действует ещё {valid_hours} часов.", + "SUBSCRIPTION_EXPIRED_DAYN": "🔥 Возвращайтесь со скидкой {discount}%\n\nПрошло {days} суток без VPN. Для вас активирована расширенная скидка {discount}% на ближайшие {valid_hours} часов.", "MY_BALANCE_BUTTON": "💰 Мой баланс", "MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка", "NO": "❌ Нет", From 51a5c4a65e2fd72363c50799d5678a220df0dca0 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:21:22 +0300 Subject: [PATCH 035/146] Revert "Add notification scheduling and admin controls" --- app/database/crud/subscription.py | 8 +- app/database/models.py | 9 +- app/database/universal_migration.py | 46 +-- app/handlers/admin/monitoring.py | 305 +-------------- app/keyboards/admin.py | 3 - app/services/monitoring_service.py | 350 +----------------- app/services/notification_settings_service.py | 195 ---------- app/services/remnawave_service.py | 61 +-- app/states.py | 6 - locales/en.json | 5 - locales/ru.json | 5 - 11 files changed, 23 insertions(+), 970 deletions(-) delete mode 100644 app/services/notification_settings_service.py diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 03bce858..051c2369 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -965,9 +965,7 @@ async def create_subscription( device_limit: int = 1, connected_squads: list = None, remnawave_short_uuid: str = None, - subscription_url: str = "", - first_connected_at: datetime | None = None, - last_connected_at: datetime | None = None, + subscription_url: str = "" ) -> Subscription: if end_date is None: @@ -986,9 +984,7 @@ async def create_subscription( device_limit=device_limit, connected_squads=connected_squads, remnawave_short_uuid=remnawave_short_uuid, - subscription_url=subscription_url, - first_connected_at=first_connected_at, - last_connected_at=last_connected_at + subscription_url=subscription_url ) db.add(subscription) diff --git a/app/database/models.py b/app/database/models.py index 510181aa..f9b6d8ab 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -415,15 +415,12 @@ class Subscription(Base): autopay_enabled = Column(Boolean, default=False) autopay_days_before = Column(Integer, default=3) - + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - + remnawave_short_uuid = Column(String(255), nullable=True) - - first_connected_at = Column(DateTime, nullable=True) - last_connected_at = Column(DateTime, nullable=True) - + user = relationship("User", back_populates="subscription") @property diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index dbc0dc3e..40273ff4 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1234,7 +1234,7 @@ async def fix_foreign_keys_for_user_deletion(): async def add_referral_system_columns(): logger.info("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===") - + try: async with engine.begin() as conn: db_type = await get_database_type() @@ -1282,38 +1282,6 @@ async def add_referral_system_columns(): logger.error(f"Ошибка миграции реферальной системы: {e}") return False - -async def add_subscription_connection_columns() -> bool: - logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ ПОДКЛЮЧЕНИЙ ПОДПИСОК ===") - - try: - async with engine.begin() as conn: - db_type = await get_database_type() - - for column_name in ("first_connected_at", "last_connected_at"): - column_exists = await check_column_exists("subscriptions", column_name) - if column_exists: - logger.info(f"Колонка {column_name} уже существует в subscriptions") - continue - - if db_type == "sqlite": - column_def = "TIMESTAMP" - elif db_type == "mysql": - column_def = "DATETIME" - else: - column_def = "TIMESTAMP" - - await conn.execute( - text(f"ALTER TABLE subscriptions ADD COLUMN {column_name} {column_def}") - ) - logger.info(f"Добавлена колонка {column_name} в subscriptions") - - return True - - except Exception as e: - logger.error(f"Ошибка добавления полей подключений подписок: {e}") - return False - async def create_subscription_conversions_table(): table_exists = await check_table_exists('subscription_conversions') if table_exists: @@ -1477,11 +1445,7 @@ async def run_universal_migration(): referral_migration_success = await add_referral_system_columns() if not referral_migration_success: logger.warning("⚠️ Проблемы с миграцией реферальной системы") - - connections_added = await add_subscription_connection_columns() - if not connections_added: - logger.warning("⚠️ Проблемы с добавлением полей подключений подписок") - + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===") cryptobot_created = await create_cryptobot_payments_table() if cryptobot_created: @@ -1687,8 +1651,6 @@ async def check_migration_status(): "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, "users_auto_promo_group_assigned_column": False, - "subscriptions_first_connected_column": False, - "subscriptions_last_connected_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1704,8 +1666,6 @@ async def check_migration_status(): status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') - status["subscriptions_first_connected_column"] = await check_column_exists('subscriptions', 'first_connected_at') - status["subscriptions_last_connected_column"] = await check_column_exists('subscriptions', 'last_connected_at') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -1741,8 +1701,6 @@ async def check_migration_status(): "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", - "subscriptions_first_connected_column": "Колонка first_connected_at у подписок", - "subscriptions_last_connected_column": "Колонка last_connected_at у подписок", } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index a6dbc9f1..be876876 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -2,123 +2,21 @@ import asyncio import logging from datetime import datetime, timedelta from aiogram import Router, F -from aiogram.fsm.context import FSMContext -from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.types import Message, CallbackQuery from aiogram.filters import Command from app.config import settings from app.database.database import get_db from app.services.monitoring_service import monitoring_service -from app.services.notification_settings_service import AutoNotificationSettingsService from app.utils.decorators import admin_required from app.utils.pagination import paginate_list from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard from app.localization.texts import get_texts -from app.states import MonitoringNotificationStates logger = logging.getLogger(__name__) router = Router() -def _toggle_text(enabled: bool) -> str: - return "✅ Вкл" if enabled else "❌ Выкл" - - -def _build_notification_settings(language: str) -> tuple[str, InlineKeyboardMarkup]: - texts = get_texts(language) - - trial_1h_enabled = AutoNotificationSettingsService.is_trial_1h_enabled() - trial_24h_enabled = AutoNotificationSettingsService.is_trial_24h_enabled() - expired_day1_enabled = AutoNotificationSettingsService.is_expired_day1_enabled() - - expired_day23_enabled = AutoNotificationSettingsService.is_expired_day23_enabled() - day23_discount = AutoNotificationSettingsService.get_expired_day23_discount() - day23_valid = AutoNotificationSettingsService.get_expired_day23_valid_hours() - window_start, window_end = AutoNotificationSettingsService.get_expired_day23_window() - - expired_dayN_enabled = AutoNotificationSettingsService.is_expired_dayN_enabled() - dayN_discount = AutoNotificationSettingsService.get_expired_dayN_discount() - dayN_valid = AutoNotificationSettingsService.get_expired_dayN_valid_hours() - dayN_threshold = AutoNotificationSettingsService.get_expired_dayN_threshold() - - overview_lines = [ - f"⏱️ Триал +1 час — {'вкл' if trial_1h_enabled else 'выкл'}", - f"🕛 Триал +24 часа — {'вкл' if trial_24h_enabled else 'выкл'}", - f"📆 Истёкшая подписка (1 сутки) — {'вкл' if expired_day1_enabled else 'выкл'}", - ( - f"🎯 {window_start}-{window_end} дней без продления — " - f"{'вкл' if expired_day23_enabled else 'выкл'} • скидка {day23_discount}% на {day23_valid} ч" - ), - ( - f"🔥 ≥{dayN_threshold} дней без продления — " - f"{'вкл' if expired_dayN_enabled else 'выкл'} • скидка {dayN_discount}% на {dayN_valid} ч" - ), - ] - - text = ( - "🔔 Автоуведомления\n\n" - "Управляйте напоминаниями о подключении и продлении подписки.\n\n" - + "\n".join(overview_lines) - + "\n\nИспользуйте кнопки ниже для включения, отключения или изменения параметров." - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=f"⏱️ Триал +1 час: {_toggle_text(trial_1h_enabled)}", - callback_data="admin_mon_notif_toggle_trial_1h", - ) - ], - [ - InlineKeyboardButton( - text=f"🕛 Триал +24 часа: {_toggle_text(trial_24h_enabled)}", - callback_data="admin_mon_notif_toggle_trial_24h", - ) - ], - [ - InlineKeyboardButton( - text=f"📆 1 сутки после окончания: {_toggle_text(expired_day1_enabled)}", - callback_data="admin_mon_notif_toggle_expired_day1", - ) - ], - [ - InlineKeyboardButton( - text=( - f"🎯 {window_start}-{window_end} дн.: " - f"{_toggle_text(expired_day23_enabled)} ({day23_discount}%)" - ), - callback_data="admin_mon_notif_toggle_expired_day23", - ), - InlineKeyboardButton( - text="✏️ %", - callback_data="admin_mon_notif_set_day23_discount", - ), - ], - [ - InlineKeyboardButton( - text=( - f"🔥 ≥{dayN_threshold} дн.: " - f"{_toggle_text(expired_dayN_enabled)} ({dayN_discount}%)" - ), - callback_data="admin_mon_notif_toggle_expired_dayN", - ), - InlineKeyboardButton( - text="✏️ %", - callback_data="admin_mon_notif_set_dayN_discount", - ), - InlineKeyboardButton( - text="✏️ N", - callback_data="admin_mon_notif_set_dayN_threshold", - ), - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_monitoring")], - ] - ) - - return text, keyboard - - @router.callback_query(F.data == "admin_monitoring") @admin_required async def admin_monitoring_menu(callback: CallbackQuery): @@ -154,207 +52,6 @@ async def admin_monitoring_menu(callback: CallbackQuery): await callback.answer("❌ Ошибка получения данных", show_alert=True) -@router.callback_query(F.data == "admin_mon_notifications") -@admin_required -async def monitoring_notifications_menu(callback: CallbackQuery, state: FSMContext): - try: - await state.clear() - language = callback.from_user.language_code or "ru" - text, keyboard = _build_notification_settings(language) - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) - await callback.answer() - except Exception as e: - logger.error(f"Ошибка отображения настроек уведомлений: {e}") - await callback.answer("❌ Ошибка", show_alert=True) - - -@router.callback_query(F.data == "admin_mon_notif_toggle_trial_1h") -@admin_required -async def toggle_trial_1h_reminder(callback: CallbackQuery, state: FSMContext): - AutoNotificationSettingsService.set_trial_1h_enabled( - not AutoNotificationSettingsService.is_trial_1h_enabled() - ) - await monitoring_notifications_menu(callback, state) - - -@router.callback_query(F.data == "admin_mon_notif_toggle_trial_24h") -@admin_required -async def toggle_trial_24h_reminder(callback: CallbackQuery, state: FSMContext): - AutoNotificationSettingsService.set_trial_24h_enabled( - not AutoNotificationSettingsService.is_trial_24h_enabled() - ) - await monitoring_notifications_menu(callback, state) - - -@router.callback_query(F.data == "admin_mon_notif_toggle_expired_day1") -@admin_required -async def toggle_expired_day1(callback: CallbackQuery, state: FSMContext): - AutoNotificationSettingsService.set_expired_day1_enabled( - not AutoNotificationSettingsService.is_expired_day1_enabled() - ) - await monitoring_notifications_menu(callback, state) - - -@router.callback_query(F.data == "admin_mon_notif_toggle_expired_day23") -@admin_required -async def toggle_expired_day23(callback: CallbackQuery, state: FSMContext): - AutoNotificationSettingsService.set_expired_day23_enabled( - not AutoNotificationSettingsService.is_expired_day23_enabled() - ) - await monitoring_notifications_menu(callback, state) - - -@router.callback_query(F.data == "admin_mon_notif_toggle_expired_dayN") -@admin_required -async def toggle_expired_dayN(callback: CallbackQuery, state: FSMContext): - AutoNotificationSettingsService.set_expired_dayN_enabled( - not AutoNotificationSettingsService.is_expired_dayN_enabled() - ) - await monitoring_notifications_menu(callback, state) - - -@router.callback_query(F.data == "admin_mon_notif_set_day23_discount") -@admin_required -async def start_set_day23_discount(callback: CallbackQuery, state: FSMContext): - try: - await state.set_state(MonitoringNotificationStates.waiting_for_day23_discount) - language = callback.from_user.language_code or "ru" - texts = get_texts(language) - current = AutoNotificationSettingsService.get_expired_day23_discount() - prompt = ( - "🎯 Скидка на 2-3 сутки\n\n" - "Введите размер скидки в процентах (0-100), которая будет доступна в течение 24 часов.\n\n" - f"Текущее значение: {current}%" - ) - back_keyboard = InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data="admin_mon_notifications")]] - ) - await callback.message.edit_text(prompt, parse_mode="HTML", reply_markup=back_keyboard) - await callback.answer() - except Exception as e: - logger.error(f"Ошибка запроса скидки для 2-3 суток: {e}") - await callback.answer("❌ Ошибка", show_alert=True) - - -@router.callback_query(F.data == "admin_mon_notif_set_dayN_discount") -@admin_required -async def start_set_dayN_discount(callback: CallbackQuery, state: FSMContext): - try: - await state.set_state(MonitoringNotificationStates.waiting_for_dayN_discount) - language = callback.from_user.language_code or "ru" - texts = get_texts(language) - current = AutoNotificationSettingsService.get_expired_dayN_discount() - prompt = ( - "🔥 Большая скидка\n\n" - "Введите размер скидки (0-100), которая будет предложена спустя N суток после окончания подписки.\n\n" - f"Текущее значение: {current}%" - ) - back_keyboard = InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data="admin_mon_notifications")]] - ) - await callback.message.edit_text(prompt, parse_mode="HTML", reply_markup=back_keyboard) - await callback.answer() - except Exception as e: - logger.error(f"Ошибка запроса большой скидки: {e}") - await callback.answer("❌ Ошибка", show_alert=True) - - -@router.callback_query(F.data == "admin_mon_notif_set_dayN_threshold") -@admin_required -async def start_set_dayN_threshold(callback: CallbackQuery, state: FSMContext): - try: - await state.set_state(MonitoringNotificationStates.waiting_for_dayN_threshold) - language = callback.from_user.language_code or "ru" - texts = get_texts(language) - current = AutoNotificationSettingsService.get_expired_dayN_threshold() - prompt = ( - "📅 Порог для большой скидки\n\n" - "Введите через сколько суток после окончания подписки предлагать вторую скидку.\n" - "Рекомендуем значение не меньше 4, чтобы не пересекаться с предыдущими уведомлениями.\n\n" - f"Текущее значение: {current}" - ) - back_keyboard = InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data="admin_mon_notifications")]] - ) - await callback.message.edit_text(prompt, parse_mode="HTML", reply_markup=back_keyboard) - await callback.answer() - except Exception as e: - logger.error(f"Ошибка запроса порога для большой скидки: {e}") - await callback.answer("❌ Ошибка", show_alert=True) - -@router.message(MonitoringNotificationStates.waiting_for_day23_discount) -@admin_required -async def handle_day23_discount(message: Message, state: FSMContext): - value_raw = (message.text or "").strip() - try: - value = int(value_raw) - except ValueError: - await message.answer("❌ Введите целое число от 0 до 100") - return - - if value < 0 or value > 100: - await message.answer("❌ Допустимый диапазон скидки: 0-100") - return - - AutoNotificationSettingsService.set_expired_day23_discount(value) - await state.clear() - texts = get_texts(message.from_user.language_code or "ru") - keyboard = InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data="admin_mon_notifications")]] - ) - await message.answer(f"✅ Скидка установлена на {value}%", reply_markup=keyboard) - - -@router.message(MonitoringNotificationStates.waiting_for_dayN_discount) -@admin_required -async def handle_dayN_discount(message: Message, state: FSMContext): - value_raw = (message.text or "").strip() - try: - value = int(value_raw) - except ValueError: - await message.answer("❌ Введите целое число от 0 до 100") - return - - if value < 0 or value > 100: - await message.answer("❌ Допустимый диапазон скидки: 0-100") - return - - AutoNotificationSettingsService.set_expired_dayN_discount(value) - await state.clear() - texts = get_texts(message.from_user.language_code or "ru") - keyboard = InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data="admin_mon_notifications")]] - ) - await message.answer(f"✅ Скидка установлена на {value}%", reply_markup=keyboard) - - -@router.message(MonitoringNotificationStates.waiting_for_dayN_threshold) -@admin_required -async def handle_dayN_threshold(message: Message, state: FSMContext): - value_raw = (message.text or "").strip() - try: - days = int(value_raw) - except ValueError: - await message.answer("❌ Введите целое число (минимум 4)") - return - - if days < 4: - await message.answer("❌ Минимальное значение — 4") - return - - if days > 60: - await message.answer("❌ Максимальное значение — 60 суток") - return - - AutoNotificationSettingsService.set_expired_dayN_threshold(days) - await state.clear() - texts = get_texts(message.from_user.language_code or "ru") - keyboard = InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data="admin_mon_notifications")]] - ) - await message.answer(f"✅ Порог установлен на {days} суток", reply_markup=keyboard) - - @router.callback_query(F.data == "admin_mon_start") @admin_required async def start_monitoring_callback(callback: CallbackQuery): diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 7921fc99..8219147b 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -778,9 +778,6 @@ def get_monitoring_keyboard() -> InlineKeyboardMarkup: InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="admin_mon_force_check"), InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs") ], - [ - InlineKeyboardButton(text="🔔 Уведомления", callback_data="admin_mon_notifications") - ], [ InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"), InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics") diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 2afd2801..a190aec4 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -23,7 +23,6 @@ from app.database.crud.notification import ( ) from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus from app.services.subscription_service import SubscriptionService -from app.services.notification_settings_service import AutoNotificationSettingsService from app.services.payment_service import PaymentService from app.localization.texts import get_texts @@ -83,10 +82,8 @@ class MonitoringService: await self._cleanup_notification_cache() await self._check_expired_subscriptions(db) - await self._check_expired_followups(db) await self._check_expiring_subscriptions(db) - await self._check_trial_expiring_soon(db) - await self._check_trial_connection_reminders(db) + await self._check_trial_expiring_soon(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) await self._sync_with_remnawave(db) @@ -120,7 +117,7 @@ class MonitoringService: async def _check_expired_subscriptions(self, db: AsyncSession): try: expired_subscriptions = await get_expired_subscriptions(db) - + for subscription in expired_subscriptions: from app.database.crud.subscription import expire_subscription await expire_subscription(db, subscription) @@ -144,134 +141,6 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки истёкших подписок: {e}") - async def _check_expired_followups(self, db: AsyncSession): - if not settings.ENABLE_NOTIFICATIONS or not self.bot: - return - - try: - result = await db.execute( - select(Subscription) - .options(selectinload(Subscription.user)) - .where( - and_( - Subscription.status == SubscriptionStatus.EXPIRED.value, - Subscription.is_trial == False, - Subscription.end_date.isnot(None), - ) - ) - ) - subscriptions = result.scalars().all() - if not subscriptions: - return - - now = datetime.utcnow() - - day1_enabled = AutoNotificationSettingsService.is_expired_day1_enabled() - day23_enabled = AutoNotificationSettingsService.is_expired_day23_enabled() - dayN_enabled = AutoNotificationSettingsService.is_expired_dayN_enabled() - - day23_discount = AutoNotificationSettingsService.get_expired_day23_discount() - day23_valid = AutoNotificationSettingsService.get_expired_day23_valid_hours() - window_start, window_end = AutoNotificationSettingsService.get_expired_day23_window() - - dayN_threshold = AutoNotificationSettingsService.get_expired_dayN_threshold() - dayN_discount = AutoNotificationSettingsService.get_expired_dayN_discount() - dayN_valid = AutoNotificationSettingsService.get_expired_dayN_valid_hours() - - counters = {"day1": 0, "day23": 0, "dayN": 0} - - for subscription in subscriptions: - user = subscription.user - if not user or not subscription.end_date: - continue - - elapsed = now - subscription.end_date - if elapsed.total_seconds() < 0: - continue - - elapsed_days = elapsed.total_seconds() / 86400 - days_since = max(1, int(elapsed_days)) - - if dayN_enabled and elapsed_days >= dayN_threshold: - if not await notification_sent(db, user.id, subscription.id, "expired_discount_dayN"): - sent = await self._send_expired_followup_notification( - user, - "dayN", - discount_percent=dayN_discount, - valid_hours=dayN_valid, - days_since=days_since, - threshold=dayN_threshold, - ) - if sent: - await record_notification(db, user.id, subscription.id, "expired_discount_dayN") - counters["dayN"] += 1 - continue - - if ( - day23_enabled - and elapsed_days >= window_start - and elapsed_days < (window_end + 1) - ): - if not await notification_sent(db, user.id, subscription.id, "expired_discount_day23"): - sent = await self._send_expired_followup_notification( - user, - "day23", - discount_percent=day23_discount, - valid_hours=day23_valid, - days_since=days_since, - ) - if sent: - await record_notification(db, user.id, subscription.id, "expired_discount_day23") - counters["day23"] += 1 - continue - - if day1_enabled and 1 <= elapsed_days < 2: - if not await notification_sent(db, user.id, subscription.id, "expired_followup_day1"): - sent = await self._send_expired_followup_notification( - user, - "day1", - days_since=days_since, - ) - if sent: - await record_notification(db, user.id, subscription.id, "expired_followup_day1") - counters["day1"] += 1 - - if counters["day1"]: - await self._log_monitoring_event( - db, - "expired_followup_day1_sent", - f"Отправлено {counters['day1']} напоминаний через 1 сутки", - {"count": counters["day1"]}, - ) - - if counters["day23"]: - await self._log_monitoring_event( - db, - "expired_followup_day23_sent", - f"Отправлено {counters['day23']} предложений со скидкой {day23_discount}%", - { - "count": counters["day23"], - "discount_percent": day23_discount, - "valid_hours": day23_valid, - }, - ) - - if counters["dayN"]: - await self._log_monitoring_event( - db, - "expired_followup_dayN_sent", - f"Отправлено {counters['dayN']} предложений со скидкой {dayN_discount}%", - { - "count": counters["dayN"], - "discount_percent": dayN_discount, - "valid_hours": dayN_valid, - "threshold_days": dayN_threshold, - }, - ) - - except Exception as e: - logger.error(f"Ошибка проверки последующих уведомлений по истекшим подпискам: {e}") - async def update_remnawave_user( self, db: AsyncSession, @@ -381,7 +250,7 @@ class MonitoringService: async def _check_trial_expiring_soon(self, db: AsyncSession): try: threshold_time = datetime.utcnow() + timedelta(hours=2) - + result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -419,95 +288,11 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}") - - async def _check_trial_connection_reminders(self, db: AsyncSession): - if not settings.ENABLE_NOTIFICATIONS or not self.bot: - return - - thresholds: list[tuple[str, timedelta]] = [] - if AutoNotificationSettingsService.is_trial_1h_enabled(): - thresholds.append(("trial_no_connection_1h", timedelta(hours=1))) - if AutoNotificationSettingsService.is_trial_24h_enabled(): - thresholds.append(("trial_no_connection_24h", timedelta(hours=24))) - - if not thresholds: - return - - try: - result = await db.execute( - select(Subscription) - .options(selectinload(Subscription.user)) - .where( - and_( - Subscription.status == SubscriptionStatus.ACTIVE.value, - Subscription.is_trial == True, - Subscription.start_date.isnot(None), - ) - ) - ) - subscriptions = result.scalars().all() - if not subscriptions: - return - - now = datetime.utcnow() - sent_counts = {key: 0 for key, _ in thresholds} - - for subscription in subscriptions: - user = subscription.user - if not user or not subscription.start_date: - continue - - if subscription.end_date and subscription.end_date <= now: - continue - - has_connected = bool( - (subscription.first_connected_at) - or (subscription.last_connected_at) - or (subscription.traffic_used_gb or 0) > 0.01 - ) - if has_connected: - continue - - elapsed = now - subscription.start_date - if elapsed.total_seconds() <= 0: - continue - - for notification_type, delta in thresholds: - hours = int(delta.total_seconds() // 3600) - - if notification_type == "trial_no_connection_1h" and elapsed >= timedelta(hours=24): - continue - - if elapsed >= delta: - if await notification_sent(db, user.id, subscription.id, notification_type): - continue - - sent = await self._send_trial_no_connection_notification(user, hours) - if sent: - await record_notification(db, user.id, subscription.id, notification_type) - sent_counts[notification_type] += 1 - - log_labels = { - "trial_no_connection_1h": "о подключении через 1 час", - "trial_no_connection_24h": "о подключении через 24 часа", - } - - for notif_type, count in sent_counts.items(): - if count > 0: - await self._log_monitoring_event( - db, - f"{notif_type}_sent", - f"Отправлено {count} напоминаний {log_labels.get(notif_type, notif_type)}", - {"count": count}, - ) - - except Exception as e: - logger.error(f"Ошибка проверки напоминаний о подключении триала: {e}") - + async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]: current_time = datetime.utcnow() threshold_date = current_time + timedelta(days=days_before) - + result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -632,83 +417,11 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}") return False - - async def _send_expired_followup_notification( - self, - user: User, - variant: str, - *, - discount_percent: int = 0, - valid_hours: int = 24, - days_since: int = 1, - threshold: int = 0, - ) -> bool: - try: - texts = get_texts(user.language) - - if variant == "day1": - message = texts.t( - "SUBSCRIPTION_EXPIRED_DAY1", - """⛔ Подписка закончилась - -Прошли {days} сутки без продления. Доступ к серверам закрыт, но вы можете восстановить его в один клик. - -Нажмите "Купить подписку" или напишите в поддержку, если нужна помощь.""", - ).format(days=days_since) - elif variant == "day23": - message = texts.t( - "SUBSCRIPTION_EXPIRED_DAY23", - """🎯 Скидка {discount}% на продление - -Подписка завершилась {days} дня назад. Воспользуйтесь временной скидкой {discount}% — предложение действует ещё {valid_hours} часов. - -Продлите подписку сейчас и вернём доступ моментально.""", - ).format(discount=discount_percent, valid_hours=valid_hours, days=days_since) - elif variant == "dayN": - message = texts.t( - "SUBSCRIPTION_EXPIRED_DAYN", - """🔥 Возвращайтесь со скидкой {discount}% - -Прошло уже {days} суток без VPN. Для вас действует расширенная скидка {discount}% на ближайшие {valid_hours} часов. - -Нажмите "Купить подписку" — доступ восстановится сразу после оплаты.""", - ).format( - discount=discount_percent, - valid_hours=valid_hours, - days=days_since, - threshold=threshold, - ) - else: - return False - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy")], - [InlineKeyboardButton(text=texts.BALANCE_TOP_UP, callback_data="balance_topup")], - [InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")], - ] - ) - - await self.bot.send_message( - user.telegram_id, - message, - parse_mode="HTML", - reply_markup=keyboard, - ) - return True - - except Exception as e: - logger.error( - f"Ошибка отправки follow-up уведомления пользователю {user.telegram_id}: {e}" - ) - return False - + async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool: try: from app.utils.formatters import format_days_declension - + texts = get_texts(user.language) days_text = format_days_declension(days, user.language) @@ -749,57 +462,10 @@ class MonitoringService: logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}") return False - async def _send_trial_no_connection_notification(self, user: User, hours: int) -> bool: - try: - texts = get_texts(user.language) - - if hours <= 1: - message = texts.t( - "TRIAL_NO_CONNECTION_1H", - """⏳ Вы ещё не подключились к VPN - -Прошел {hours} час после активации тестовой подписки, но мы не видим подключений. - -Нажмите «Подключиться», чтобы открыть инструкцию, или загляните в поддержку — поможем настроить всё за пару минут.""", - ).format(hours=hours) - else: - message = texts.t( - "TRIAL_NO_CONNECTION_24H", - """⌛️ Тест ещё не использован - -Прошли {hours} часа после активации тестовой подписки, и подключений всё ещё нет. - -Вернитесь в раздел «Подписка», нажмите «Подключиться» и следуйте инструкции. Если что-то не получается — поддержка всегда рядом.""", - ).format(hours=hours) - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], - [InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")], - [InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")], - ] - ) - - await self.bot.send_message( - user.telegram_id, - message, - parse_mode="HTML", - reply_markup=keyboard, - ) - return True - - except Exception as e: - logger.error( - f"Ошибка отправки напоминания о подключении триала пользователю {user.telegram_id}: {e}" - ) - return False - async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool: try: texts = get_texts(user.language) - + message = f""" 🎁 Тестовая подписка скоро закончится! diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py deleted file mode 100644 index 43b7896a..00000000 --- a/app/services/notification_settings_service.py +++ /dev/null @@ -1,195 +0,0 @@ -import json -import logging -from pathlib import Path -from typing import Any, Dict - - -logger = logging.getLogger(__name__) - - -class AutoNotificationSettingsService: - """Runtime storage for auto notification settings. - - Settings are stored in a JSON file inside the data directory so they can be - tweaked from the admin panel without restarting the bot. Only overrides are - persisted – sensible defaults are provided in code. - """ - - _storage_path: Path = Path("data/auto_notification_settings.json") - _defaults: Dict[str, Any] = { - "trial_no_connection_1h_enabled": True, - "trial_no_connection_24h_enabled": True, - "expired_day1_enabled": True, - "expired_day23_enabled": True, - "expired_day23_discount_percent": 15, - "expired_day23_valid_hours": 24, - "expired_day23_window_start": 2, - "expired_day23_window_end": 3, - "expired_dayN_enabled": True, - "expired_dayN_discount_percent": 25, - "expired_dayN_valid_hours": 24, - "expired_dayN_threshold": 7, - } - _data: Dict[str, Any] = {} - _loaded: bool = False - - @classmethod - def _ensure_loaded(cls) -> None: - if cls._loaded: - return - - try: - cls._storage_path.parent.mkdir(parents=True, exist_ok=True) - except Exception as e: - logger.error("Не удалось создать директорию настроек уведомлений: %s", e) - - if cls._storage_path.exists(): - try: - raw = cls._storage_path.read_text(encoding="utf-8") - data = json.loads(raw) if raw.strip() else {} - if isinstance(data, dict): - cls._data = data - else: - cls._data = {} - except Exception as e: - logger.error("Ошибка загрузки настроек уведомлений: %s", e) - cls._data = {} - else: - cls._data = {} - - cls._loaded = True - - @classmethod - def _get_merged(cls) -> Dict[str, Any]: - cls._ensure_loaded() - merged = dict(cls._defaults) - merged.update(cls._data) - return merged - - @classmethod - def _save(cls) -> bool: - try: - cls._storage_path.parent.mkdir(parents=True, exist_ok=True) - data_to_save = cls._get_merged() - cls._storage_path.write_text( - json.dumps(data_to_save, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - return True - except Exception as e: - logger.error("Ошибка сохранения настроек уведомлений: %s", e) - return False - - @classmethod - def get_settings(cls) -> Dict[str, Any]: - """Returns a copy of current settings with defaults applied.""" - - return cls._get_merged().copy() - - @classmethod - def _set_value(cls, key: str, value: Any) -> bool: - cls._ensure_loaded() - cls._data[key] = value - return cls._save() - - # Trial reminders - @classmethod - def is_trial_1h_enabled(cls) -> bool: - return bool(cls.get_settings().get("trial_no_connection_1h_enabled", True)) - - @classmethod - def set_trial_1h_enabled(cls, enabled: bool) -> bool: - return cls._set_value("trial_no_connection_1h_enabled", bool(enabled)) - - @classmethod - def is_trial_24h_enabled(cls) -> bool: - return bool(cls.get_settings().get("trial_no_connection_24h_enabled", True)) - - @classmethod - def set_trial_24h_enabled(cls, enabled: bool) -> bool: - return cls._set_value("trial_no_connection_24h_enabled", bool(enabled)) - - # Expired subscription follow-ups - @classmethod - def is_expired_day1_enabled(cls) -> bool: - return bool(cls.get_settings().get("expired_day1_enabled", True)) - - @classmethod - def set_expired_day1_enabled(cls, enabled: bool) -> bool: - return cls._set_value("expired_day1_enabled", bool(enabled)) - - @classmethod - def is_expired_day23_enabled(cls) -> bool: - return bool(cls.get_settings().get("expired_day23_enabled", True)) - - @classmethod - def set_expired_day23_enabled(cls, enabled: bool) -> bool: - return cls._set_value("expired_day23_enabled", bool(enabled)) - - @classmethod - def get_expired_day23_discount(cls) -> int: - try: - return int(cls.get_settings().get("expired_day23_discount_percent", 15)) - except Exception: - return 15 - - @classmethod - def set_expired_day23_discount(cls, percent: int) -> bool: - return cls._set_value("expired_day23_discount_percent", int(percent)) - - @classmethod - def get_expired_day23_valid_hours(cls) -> int: - try: - return int(cls.get_settings().get("expired_day23_valid_hours", 24)) - except Exception: - return 24 - - @classmethod - def get_expired_day23_window(cls) -> tuple[int, int]: - settings = cls.get_settings() - try: - start = int(settings.get("expired_day23_window_start", 2)) - except Exception: - start = 2 - try: - end = int(settings.get("expired_day23_window_end", 3)) - except Exception: - end = 3 - return start, end - - @classmethod - def is_expired_dayN_enabled(cls) -> bool: - return bool(cls.get_settings().get("expired_dayN_enabled", True)) - - @classmethod - def set_expired_dayN_enabled(cls, enabled: bool) -> bool: - return cls._set_value("expired_dayN_enabled", bool(enabled)) - - @classmethod - def get_expired_dayN_discount(cls) -> int: - try: - return int(cls.get_settings().get("expired_dayN_discount_percent", 25)) - except Exception: - return 25 - - @classmethod - def set_expired_dayN_discount(cls, percent: int) -> bool: - return cls._set_value("expired_dayN_discount_percent", int(percent)) - - @classmethod - def get_expired_dayN_valid_hours(cls) -> int: - try: - return int(cls.get_settings().get("expired_dayN_valid_hours", 24)) - except Exception: - return 24 - - @classmethod - def get_expired_dayN_threshold(cls) -> int: - try: - return int(cls.get_settings().get("expired_dayN_threshold", 7)) - except Exception: - return 7 - - @classmethod - def set_expired_dayN_threshold(cls, days: int) -> bool: - return cls._set_value("expired_dayN_threshold", int(days)) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 403ba0ad..dd6ee50c 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -35,7 +35,7 @@ class RemnaWaveService: def _parse_remnawave_date(self, date_str: str) -> datetime: if not date_str: return datetime.utcnow() + timedelta(days=30) - + try: cleaned_date = date_str.strip() @@ -59,33 +59,7 @@ class RemnaWaveService: except Exception as e: logger.warning(f"⚠️ Не удалось распарсить дату '{date_str}': {e}. Используем дефолтную дату.") return datetime.utcnow() + timedelta(days=30) - - def _parse_optional_remnawave_date(self, date_str: Optional[str]) -> Optional[datetime]: - if not date_str: - return None - - try: - cleaned_date = date_str.strip() - - if cleaned_date.endswith('Z'): - cleaned_date = cleaned_date[:-1] + '+00:00' - - if '+00:00+00:00' in cleaned_date: - cleaned_date = cleaned_date.replace('+00:00+00:00', '+00:00') - - cleaned_date = re.sub(r'(\+\d{2}:\d{2})\+\d{2}:\d{2}$', r'\1', cleaned_date) - - parsed_date = datetime.fromisoformat(cleaned_date) - - if parsed_date.tzinfo is not None: - parsed_date = parsed_date.replace(tzinfo=None) - - return parsed_date - - except Exception as e: - logger.debug(f"Не удалось распарсить дату '{date_str}': {e}") - return None - + async def get_system_statistics(self) -> Dict[str, Any]: try: async with self.api as api: @@ -650,7 +624,7 @@ class RemnaWaveService: used_traffic_bytes = panel_user.get('usedTrafficBytes', 0) traffic_used_gb = used_traffic_bytes / (1024**3) - + active_squads = panel_user.get('activeInternalSquads', []) squad_uuids = [] if isinstance(active_squads, list): @@ -660,25 +634,17 @@ class RemnaWaveService: elif isinstance(squad, str): squad_uuids.append(squad) - first_connected_at = self._parse_optional_remnawave_date(panel_user.get('firstConnectedAt')) - last_connected_at = ( - self._parse_optional_remnawave_date(panel_user.get('onlineAt')) - or self._parse_optional_remnawave_date(panel_user.get('subLastOpenedAt')) - ) - subscription_data = { 'user_id': user.id, 'status': status.value, - 'is_trial': False, + 'is_trial': False, 'end_date': expire_at, 'traffic_limit_gb': traffic_limit_gb, 'traffic_used_gb': traffic_used_gb, 'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1, 'connected_squads': squad_uuids, 'remnawave_short_uuid': panel_user.get('shortUuid'), - 'subscription_url': panel_user.get('subscriptionUrl', ''), - 'first_connected_at': first_connected_at, - 'last_connected_at': last_connected_at, + 'subscription_url': panel_user.get('subscriptionUrl', '') } subscription = await create_subscription(db, **subscription_data) @@ -760,26 +726,13 @@ class RemnaWaveService: if subscription.device_limit != device_limit: subscription.device_limit = device_limit logger.debug(f"Обновлен лимит устройств: {device_limit}") - + if not subscription.remnawave_short_uuid: subscription.remnawave_short_uuid = panel_user.get('shortUuid') - + panel_url = panel_user.get('subscriptionUrl', '') if not subscription.subscription_url or subscription.subscription_url != panel_url: subscription.subscription_url = panel_url - - first_connected_at = self._parse_optional_remnawave_date(panel_user.get('firstConnectedAt')) - if first_connected_at and subscription.first_connected_at != first_connected_at: - subscription.first_connected_at = first_connected_at - logger.debug(f"Обновлено первое подключение: {first_connected_at}") - - last_connected_at = ( - self._parse_optional_remnawave_date(panel_user.get('onlineAt')) - or self._parse_optional_remnawave_date(panel_user.get('subLastOpenedAt')) - ) - if last_connected_at and subscription.last_connected_at != last_connected_at: - subscription.last_connected_at = last_connected_at - logger.debug(f"Обновлено последнее подключение: {last_connected_at}") active_squads = panel_user.get('activeInternalSquads', []) squad_uuids = [] diff --git a/app/states.py b/app/states.py index aec39152..45e87e21 100644 --- a/app/states.py +++ b/app/states.py @@ -138,9 +138,3 @@ class AdminSubmenuStates(StatesGroup): in_communications_submenu = State() in_settings_submenu = State() in_system_submenu = State() - - -class MonitoringNotificationStates(StatesGroup): - waiting_for_day23_discount = State() - waiting_for_dayN_discount = State() - waiting_for_dayN_threshold = State() diff --git a/locales/en.json b/locales/en.json index e568fe18..1b416564 100644 --- a/locales/en.json +++ b/locales/en.json @@ -48,11 +48,6 @@ "MENU_BALANCE": "💰 Balance", "MENU_SUBSCRIPTION": "📱 Subscription", "MENU_TRIAL": "🎁 Trial subscription", - "TRIAL_NO_CONNECTION_1H": "⏳ You haven't connected yet\n\nIt's been {hours} hour since you activated the trial, but we still haven't seen any connections.\n\nTap “Connect” to open the setup guide or contact support — we'll help you in minutes.", - "TRIAL_NO_CONNECTION_24H": "⌛️ Your trial is still unused\n\n{hours} hours have passed since activation and no connections were detected.\n\nOpen the “Subscription” section, press “Connect” and follow the instructions. If you need help, support is a tap away.", - "SUBSCRIPTION_EXPIRED_DAY1": "⛔ Your subscription expired\n\nIt's been {days} day(s) without renewal. Server access is disabled until you renew the subscription.", - "SUBSCRIPTION_EXPIRED_DAY23": "🎯 {discount}% off renewal\n\nYour subscription ended {days} day(s) ago. Use the {discount}% discount — it stays active for the next {valid_hours} hours.", - "SUBSCRIPTION_EXPIRED_DAYN": "🔥 Come back with {discount}% off\n\nIt's been {days} day(s) without VPN. A {discount}% discount is active for the next {valid_hours} hours just for you.", "MY_BALANCE_BUTTON": "💰 My balance", "MY_SUBSCRIPTION_BUTTON": "📱 My subscription", "NO": "❌ No", diff --git a/locales/ru.json b/locales/ru.json index ea2dcdc5..736d38e1 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -225,11 +225,6 @@ "MENU_SUBSCRIPTION": "📱 Подписка", "MENU_SUPPORT": "🛠️ Техподдержка", "MENU_TRIAL": "🧪 Тестовая подписка", - "TRIAL_NO_CONNECTION_1H": "⏳ Вы ещё не подключились к VPN\n\nПрошел {hours} час после активации тестовой подписки, но мы не фиксируем подключений.\n\nНажмите «Подключиться», чтобы открыть инструкцию, или напишите в поддержку — поможем настроить всё за пару минут.", - "TRIAL_NO_CONNECTION_24H": "⌛️ Тест ещё не использован\n\nПрошли {hours} часа после активации тестовой подписки, и подключений всё ещё нет.\n\nВернитесь в раздел «Подписка», нажмите «Подключиться» и следуйте инструкции. Если что-то не получается — поддержка всегда рядом.", - "SUBSCRIPTION_EXPIRED_DAY1": "⛔ Подписка закончилась\n\nПрошли {days} сутки без продления, доступ к серверам заблокирован. Продлите подписку, чтобы мгновенно вернуть доступ.", - "SUBSCRIPTION_EXPIRED_DAY23": "🎯 Скидка {discount}% на продление\n\nПодписка завершилась {days} дня назад. Воспользуйтесь персональной скидкой {discount}% — предложение действует ещё {valid_hours} часов.", - "SUBSCRIPTION_EXPIRED_DAYN": "🔥 Возвращайтесь со скидкой {discount}%\n\nПрошло {days} суток без VPN. Для вас активирована расширенная скидка {discount}% на ближайшие {valid_hours} часов.", "MY_BALANCE_BUTTON": "💰 Мой баланс", "MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка", "NO": "❌ Нет", From 7e19a5a1ffae8d162a8c387add62423e1b6ea704 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:26:55 +0300 Subject: [PATCH 036/146] Add monitoring settings entry to admin menus --- app/database/crud/discount_offer.py | 90 +++++ app/database/models.py | 27 +- app/database/universal_migration.py | 95 +++++ app/handlers/admin/monitoring.py | 289 ++++++++++++++ app/handlers/subscription.py | 82 +++- app/keyboards/admin.py | 10 +- app/services/monitoring_service.py | 360 +++++++++++++++++- app/services/notification_settings_service.py | 249 ++++++++++++ app/states.py | 5 +- locales/en.json | 21 +- locales/ru.json | 21 +- 11 files changed, 1233 insertions(+), 16 deletions(-) create mode 100644 app/database/crud/discount_offer.py create mode 100644 app/services/notification_settings_service.py diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py new file mode 100644 index 00000000..eaa789ae --- /dev/null +++ b/app/database/crud/discount_offer.py @@ -0,0 +1,90 @@ +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import DiscountOffer + + +async def upsert_discount_offer( + db: AsyncSession, + *, + user_id: int, + subscription_id: Optional[int], + notification_type: str, + discount_percent: int, + bonus_amount_kopeks: int, + valid_hours: int, +) -> DiscountOffer: + """Create or refresh a discount offer for a user.""" + + expires_at = datetime.utcnow() + timedelta(hours=valid_hours) + + result = await db.execute( + select(DiscountOffer) + .where( + DiscountOffer.user_id == user_id, + DiscountOffer.notification_type == notification_type, + DiscountOffer.is_active == True, # noqa: E712 + ) + .order_by(DiscountOffer.created_at.desc()) + ) + offer = result.scalars().first() + + if offer and offer.claimed_at is None: + offer.discount_percent = discount_percent + offer.bonus_amount_kopeks = bonus_amount_kopeks + offer.expires_at = expires_at + offer.subscription_id = subscription_id + else: + offer = DiscountOffer( + user_id=user_id, + subscription_id=subscription_id, + notification_type=notification_type, + discount_percent=discount_percent, + bonus_amount_kopeks=bonus_amount_kopeks, + expires_at=expires_at, + is_active=True, + ) + db.add(offer) + + await db.commit() + await db.refresh(offer) + return offer + + +async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]: + result = await db.execute( + select(DiscountOffer).where(DiscountOffer.id == offer_id) + ) + return result.scalar_one_or_none() + + +async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer: + offer.claimed_at = datetime.utcnow() + offer.is_active = False + await db.commit() + await db.refresh(offer) + return offer + + +async def deactivate_expired_offers(db: AsyncSession) -> int: + now = datetime.utcnow() + result = await db.execute( + select(DiscountOffer).where( + DiscountOffer.is_active == True, # noqa: E712 + DiscountOffer.expires_at < now, + ) + ) + offers = result.scalars().all() + if not offers: + return 0 + + count = 0 + for offer in offers: + offer.is_active = False + count += 1 + + await db.commit() + return count diff --git a/app/database/models.py b/app/database/models.py index f9b6d8ab..91a7a360 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -14,6 +14,7 @@ from sqlalchemy import ( JSON, BigInteger, UniqueConstraint, + Index, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -358,6 +359,7 @@ class User(Base): subscription = relationship("Subscription", back_populates="user", uselist=False) transactions = relationship("Transaction", back_populates="user") referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user") + discount_offers = relationship("DiscountOffer", back_populates="user") lifetime_used_traffic_bytes = Column(BigInteger, default=0) auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) last_remnawave_sync = Column(DateTime, nullable=True) @@ -420,8 +422,9 @@ class Subscription(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) remnawave_short_uuid = Column(String(255), nullable=True) - + user = relationship("User", back_populates="subscription") + discount_offers = relationship("DiscountOffer", back_populates="subscription") @property def is_active(self) -> bool: @@ -765,6 +768,28 @@ class SentNotification(Base): user = relationship("User", backref="sent_notifications") subscription = relationship("Subscription", backref="sent_notifications") + +class DiscountOffer(Base): + __tablename__ = "discount_offers" + __table_args__ = ( + Index("ix_discount_offers_user_type", "user_id", "notification_type"), + ) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True) + notification_type = Column(String(50), nullable=False) + discount_percent = Column(Integer, nullable=False, default=0) + bonus_amount_kopeks = Column(Integer, nullable=False, default=0) + expires_at = Column(DateTime, nullable=False) + claimed_at = Column(DateTime, nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + user = relationship("User", back_populates="discount_offers") + subscription = relationship("Subscription", back_populates="discount_offers") + class BroadcastHistory(Base): __tablename__ = "broadcast_history" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 40273ff4..522747f0 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -520,6 +520,94 @@ async def create_pal24_payments_table(): logger.error(f"Ошибка создания таблицы pal24_payments: {e}") return False + +async def create_discount_offers_table(): + table_exists = await check_table_exists('discount_offers') + if table_exists: + logger.info("Таблица discount_offers уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text(""" + CREATE TABLE discount_offers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + subscription_id INTEGER NULL, + notification_type VARCHAR(50) NOT NULL, + discount_percent INTEGER NOT NULL DEFAULT 0, + bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, + expires_at DATETIME NOT NULL, + claimed_at DATETIME NULL, + is_active BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL + ) + """)) + await conn.execute(text(""" + CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type + ON discount_offers (user_id, notification_type) + """)) + + elif db_type == 'postgresql': + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS discount_offers ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subscription_id INTEGER NULL REFERENCES subscriptions(id) ON DELETE SET NULL, + notification_type VARCHAR(50) NOT NULL, + discount_percent INTEGER NOT NULL DEFAULT 0, + bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMP NOT NULL, + claimed_at TIMESTAMP NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """)) + await conn.execute(text(""" + CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type + ON discount_offers (user_id, notification_type) + """)) + + elif db_type == 'mysql': + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS discount_offers ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + user_id INTEGER NOT NULL, + subscription_id INTEGER NULL, + notification_type VARCHAR(50) NOT NULL, + discount_percent INTEGER NOT NULL DEFAULT 0, + bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, + expires_at DATETIME NOT NULL, + claimed_at DATETIME NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_discount_offers_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_discount_offers_subscription FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL + ) + """)) + await conn.execute(text(""" + CREATE INDEX ix_discount_offers_user_type + ON discount_offers (user_id, notification_type) + """)) + + else: + raise ValueError(f"Unsupported database type: {db_type}") + + logger.info("✅ Таблица discount_offers успешно создана") + return True + + except Exception as e: + logger.error(f"Ошибка создания таблицы discount_offers: {e}") + return False + async def create_user_messages_table(): table_exists = await check_table_exists('user_messages') if table_exists: @@ -1467,6 +1555,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей Pal24 payments") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===") + discount_created = await create_discount_offers_table() + if discount_created: + logger.info("✅ Таблица discount_offers готова") + else: + logger.warning("⚠️ Проблемы с таблицей discount_offers") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===") user_messages_created = await create_user_messages_table() if user_messages_created: diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index be876876..2c1066b4 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from aiogram import Router, F from aiogram.types import Message, CallbackQuery from aiogram.filters import Command +from aiogram.fsm.context import FSMContext from app.config import settings from app.database.database import get_db @@ -12,11 +13,77 @@ from app.utils.decorators import admin_required from app.utils.pagination import paginate_list from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard from app.localization.texts import get_texts +from app.services.notification_settings_service import NotificationSettingsService +from app.states import AdminStates logger = logging.getLogger(__name__) router = Router() +def _format_toggle(enabled: bool) -> str: + return "🟢 Вкл" if enabled else "🔴 Выкл" + + +def _build_notification_settings_view(language: str): + texts = get_texts(language) + config = NotificationSettingsService.get_config() + + second_percent = NotificationSettingsService.get_second_wave_discount_percent() + second_hours = NotificationSettingsService.get_second_wave_valid_hours() + third_percent = NotificationSettingsService.get_third_wave_discount_percent() + third_hours = NotificationSettingsService.get_third_wave_valid_hours() + third_days = NotificationSettingsService.get_third_wave_trigger_days() + + trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True)) + trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True)) + expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True)) + second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True)) + third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True)) + + summary_text = ( + "🔔 Уведомления пользователям\n\n" + f"• 1 час после триала: {trial_1h_status}\n" + f"• 24 часа после триала: {trial_24h_status}\n" + f"• 1 день после истечения: {expired_1d_status}\n" + f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n" + f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}" + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")], + [InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")], + [InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")], + [InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")], + [InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")], + [InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")], + [InlineKeyboardButton(text=f"{third_wave_status} • {third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")], + [InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")], + [InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")], + [InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")], + ]) + + return summary_text, keyboard + + +async def _render_notification_settings(callback: CallbackQuery) -> None: + language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE) + text, keyboard = _build_notification_settings_view(language) + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + + +async def _render_notification_settings_for_state(bot, chat_id: int, message_id: int, language: str) -> None: + text, keyboard = _build_notification_settings_view(language) + await bot.edit_message_text( + text, + chat_id, + message_id, + parse_mode="HTML", + reply_markup=keyboard, + ) + @router.callback_query(F.data == "admin_monitoring") @admin_required async def admin_monitoring_menu(callback: CallbackQuery): @@ -52,6 +119,180 @@ async def admin_monitoring_menu(callback: CallbackQuery): await callback.answer("❌ Ошибка получения данных", show_alert=True) +@router.callback_query(F.data == "admin_mon_settings") +@admin_required +async def admin_monitoring_settings(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + global_status = "🟢 Включены" if NotificationSettingsService.are_notifications_globally_enabled() else "🔴 Отключены" + second_percent = NotificationSettingsService.get_second_wave_discount_percent() + third_percent = NotificationSettingsService.get_third_wave_discount_percent() + third_days = NotificationSettingsService.get_third_wave_trigger_days() + + text = ( + "⚙️ Настройки мониторинга\n\n" + f"🔔 Уведомления пользователям: {global_status}\n" + f"• Скидка 2-3 дня: {second_percent}%\n" + f"• Скидка после {third_days} дней: {third_percent}%\n\n" + "Выберите раздел для настройки." + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔔 Уведомления пользователям", callback_data="admin_mon_notify_settings")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")], + ]) + + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + + except Exception as e: + logger.error(f"Ошибка отображения настроек мониторинга: {e}") + await callback.answer("❌ Не удалось открыть настройки", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_settings") +@admin_required +async def admin_notify_settings(callback: CallbackQuery): + try: + await _render_notification_settings(callback) + except Exception as e: + logger.error(f"Ошибка отображения настроек уведомлений: {e}") + await callback.answer("❌ Не удалось загрузить настройки", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_trial_1h") +@admin_required +async def toggle_trial_1h_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_trial_inactive_1h_enabled() + NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h") +@admin_required +async def toggle_trial_24h_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_trial_inactive_24h_enabled() + NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d") +@admin_required +async def toggle_expired_1d_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_expired_1d_enabled() + NotificationSettingsService.set_expired_1d_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d") +@admin_required +async def toggle_second_wave_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_second_wave_enabled() + NotificationSettingsService.set_second_wave_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd") +@admin_required +async def toggle_third_wave_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_third_wave_enabled() + NotificationSettingsService.set_third_wave_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +async def _start_notification_value_edit( + callback: CallbackQuery, + state: FSMContext, + setting_key: str, + field: str, + prompt_key: str, + default_prompt: str, +): + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await state.set_state(AdminStates.editing_notification_value) + await state.update_data( + notification_setting_key=setting_key, + notification_setting_field=field, + settings_message_chat=callback.message.chat.id, + settings_message_id=callback.message.message_id, + settings_language=language, + ) + texts = get_texts(language) + await callback.answer() + await callback.message.answer(texts.get(prompt_key, default_prompt)) + + +@router.callback_query(F.data == "admin_mon_notify_edit_2d_percent") +@admin_required +async def edit_second_wave_percent(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_second_wave", + "percent", + "NOTIFY_PROMPT_SECOND_PERCENT", + "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_2d_hours") +@admin_required +async def edit_second_wave_hours(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_second_wave", + "hours", + "NOTIFY_PROMPT_SECOND_HOURS", + "Введите количество часов действия скидки (1-168):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_nd_percent") +@admin_required +async def edit_third_wave_percent(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_third_wave", + "percent", + "NOTIFY_PROMPT_THIRD_PERCENT", + "Введите новый процент скидки для позднего предложения (0-100):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_nd_hours") +@admin_required +async def edit_third_wave_hours(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_third_wave", + "hours", + "NOTIFY_PROMPT_THIRD_HOURS", + "Введите количество часов действия скидки (1-168):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_nd_threshold") +@admin_required +async def edit_third_wave_threshold(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_third_wave", + "trigger", + "NOTIFY_PROMPT_THIRD_DAYS", + "Через сколько дней после истечения отправлять предложение? (минимум 2):", + ) + + @router.callback_query(F.data == "admin_mon_start") @admin_required async def start_monitoring_callback(callback: CallbackQuery): @@ -366,5 +607,53 @@ async def monitoring_command(message: Message): await message.answer(f"❌ Ошибка: {str(e)}") +@router.message(AdminStates.editing_notification_value) +async def process_notification_value_input(message: Message, state: FSMContext): + data = await state.get_data() + if not data: + await state.clear() + await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.") + return + + raw_value = (message.text or "").strip() + try: + value = int(raw_value) + except (TypeError, ValueError): + language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE + texts = get_texts(language) + await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Введите целое число.")) + return + + key = data.get("notification_setting_key") + field = data.get("notification_setting_field") + language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE + texts = get_texts(language) + + success = False + if key == "expired_second_wave" and field == "percent": + success = NotificationSettingsService.set_second_wave_discount_percent(value) + elif key == "expired_second_wave" and field == "hours": + success = NotificationSettingsService.set_second_wave_valid_hours(value) + elif key == "expired_third_wave" and field == "percent": + success = NotificationSettingsService.set_third_wave_discount_percent(value) + elif key == "expired_third_wave" and field == "hours": + success = NotificationSettingsService.set_third_wave_valid_hours(value) + elif key == "expired_third_wave" and field == "trigger": + success = NotificationSettingsService.set_third_wave_trigger_days(value) + + if not success: + await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова.")) + return + + await message.answer(texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены.")) + + chat_id = data.get("settings_message_chat") + message_id = data.get("settings_message_id") + if chat_id and message_id: + await _render_notification_settings_for_state(message.bot, chat_id, message_id, language) + + await state.clear() + + def register_handlers(dp): dp.include_router(router) \ No newline at end of file diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 3f0c182a..3eeee497 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -17,12 +17,13 @@ from app.database.crud.subscription import ( add_subscription_squad, update_subscription_autopay, add_subscription_servers ) -from app.database.crud.user import subtract_user_balance +from app.database.crud.user import subtract_user_balance, add_user_balance from app.database.crud.transaction import create_transaction, get_user_transactions from app.database.models import ( - User, TransactionType, SubscriptionStatus, - SubscriptionServer, Subscription + User, TransactionType, SubscriptionStatus, + SubscriptionServer, Subscription ) +from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed from app.keyboards.inline import ( get_subscription_keyboard, get_trial_keyboard, get_subscription_period_keyboard, get_traffic_packages_keyboard, @@ -4068,6 +4069,76 @@ async def handle_connect_subscription( await callback.answer() +async def claim_discount_offer( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + try: + offer_id = int(callback.data.split("_")[-1]) + except (ValueError, AttributeError): + await callback.answer( + texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), + show_alert=True, + ) + return + + offer = await get_offer_by_id(db, offer_id) + if not offer or offer.user_id != db_user.id: + await callback.answer( + texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), + show_alert=True, + ) + return + + now = datetime.utcnow() + if offer.claimed_at is not None: + await callback.answer( + texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"), + show_alert=True, + ) + return + + if not offer.is_active or offer.expires_at <= now: + offer.is_active = False + await db.commit() + await callback.answer( + texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"), + show_alert=True, + ) + return + + bonus_amount = offer.bonus_amount_kopeks or 0 + if bonus_amount > 0: + success = await add_user_balance( + db, + db_user, + bonus_amount, + texts.get("DISCOUNT_BONUS_DESCRIPTION", "Скидка за продление подписки"), + ) + if not success: + await callback.answer( + texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось начислить скидку. Попробуйте позже."), + show_alert=True, + ) + return + + await mark_offer_claimed(db, offer) + + success_message = texts.get( + "DISCOUNT_CLAIM_SUCCESS", + "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + ).format( + percent=offer.discount_percent, + amount=settings.format_price(bonus_amount), + ) + + await callback.answer("✅ Скидка активирована!", show_alert=True) + await callback.message.answer(success_message) + + async def handle_device_guide( callback: types.CallbackQuery, db_user: User, @@ -4963,6 +5034,11 @@ def register_handlers(dp: Dispatcher): F.data == "countries_apply" ) + dp.callback_query.register( + claim_discount_offer, + F.data.startswith("claim_discount_") + ) + dp.callback_query.register( handle_connect_subscription, F.data == "subscription_connect" diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 8219147b..c6dba961 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -93,14 +93,17 @@ def get_admin_support_submenu_keyboard(language: str = "ru") -> InlineKeyboardMa def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave"), InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring") ], [ - InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"), + InlineKeyboardButton(text=texts.t("ADMIN_MONITORING_SETTINGS", "🔔 Настройки уведомлений"), callback_data="admin_mon_settings"), + InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules") + ], + [ InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel") ], [ @@ -782,6 +785,9 @@ def get_monitoring_keyboard() -> InlineKeyboardMarkup: InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"), InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics") ], + [ + InlineKeyboardButton(text="⚙️ Настройки уведомлений", callback_data="admin_mon_settings") + ], [ InlineKeyboardButton(text="⬅️ Назад в админку", callback_data="admin_panel") ] diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index a190aec4..337e18f8 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -21,10 +21,15 @@ from app.database.crud.notification import ( notification_sent, record_notification, ) +from app.database.crud.discount_offer import ( + upsert_discount_offer, + deactivate_expired_offers, +) from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus from app.services.subscription_service import SubscriptionService from app.services.payment_service import PaymentService from app.localization.texts import get_texts +from app.services.notification_settings_service import NotificationSettingsService from app.external.remnawave_api import ( RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError @@ -80,10 +85,16 @@ class MonitoringService: async for db in get_db(): try: await self._cleanup_notification_cache() - + + expired_offers = await deactivate_expired_offers(db) + if expired_offers: + logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений") + await self._check_expired_subscriptions(db) await self._check_expiring_subscriptions(db) - await self._check_trial_expiring_soon(db) + await self._check_trial_expiring_soon(db) + await self._check_trial_inactivity_notifications(db) + await self._check_expired_subscription_followups(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) await self._sync_with_remnawave(db) @@ -250,7 +261,7 @@ class MonitoringService: async def _check_trial_expiring_soon(self, db: AsyncSession): try: threshold_time = datetime.utcnow() + timedelta(hours=2) - + result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -288,7 +299,202 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}") - + + async def _check_trial_inactivity_notifications(self, db: AsyncSession): + if not NotificationSettingsService.are_notifications_globally_enabled(): + return + if not self.bot: + return + + try: + now = datetime.utcnow() + one_hour_ago = now - timedelta(hours=1) + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.is_trial == True, + Subscription.start_date.isnot(None), + Subscription.start_date <= one_hour_ago, + Subscription.end_date > now, + ) + ) + ) + + subscriptions = result.scalars().all() + sent_1h = 0 + sent_24h = 0 + + for subscription in subscriptions: + user = subscription.user + if not user: + continue + + if (subscription.traffic_used_gb or 0) > 0: + continue + + start_date = subscription.start_date + if not start_date: + continue + + time_since_start = now - start_date + + if (NotificationSettingsService.is_trial_inactive_1h_enabled() + and timedelta(hours=1) <= time_since_start < timedelta(hours=24)): + if not await notification_sent(db, user.id, subscription.id, "trial_inactive_1h"): + success = await self._send_trial_inactive_notification(user, subscription, 1) + if success: + await record_notification(db, user.id, subscription.id, "trial_inactive_1h") + sent_1h += 1 + + if NotificationSettingsService.is_trial_inactive_24h_enabled() and time_since_start >= timedelta(hours=24): + if not await notification_sent(db, user.id, subscription.id, "trial_inactive_24h"): + success = await self._send_trial_inactive_notification(user, subscription, 24) + if success: + await record_notification(db, user.id, subscription.id, "trial_inactive_24h") + sent_24h += 1 + + if sent_1h or sent_24h: + await self._log_monitoring_event( + db, + "trial_inactivity_notifications", + f"Отправлено {sent_1h} уведомлений спустя 1 час и {sent_24h} спустя 24 часа", + {"sent_1h": sent_1h, "sent_24h": sent_24h}, + ) + + except Exception as e: + logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}") + + async def _check_expired_subscription_followups(self, db: AsyncSession): + if not NotificationSettingsService.are_notifications_globally_enabled(): + return + if not self.bot: + return + + try: + now = datetime.utcnow() + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.is_trial == False, + Subscription.end_date <= now, + ) + ) + ) + + subscriptions = result.scalars().all() + sent_day1 = 0 + sent_wave2 = 0 + sent_wave3 = 0 + + for subscription in subscriptions: + user = subscription.user + if not user: + continue + + if subscription.end_date is None: + continue + + time_since_end = now - subscription.end_date + if time_since_end.total_seconds() < 0: + continue + + days_since = time_since_end.total_seconds() / 86400 + + # Day 1 reminder + if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2: + if not await notification_sent(db, user.id, subscription.id, "expired_1d"): + success = await self._send_expired_day1_notification(user, subscription) + if success: + await record_notification(db, user.id, subscription.id, "expired_1d") + sent_day1 += 1 + + # Second wave (2-3 days) discount + if NotificationSettingsService.is_second_wave_enabled() and 2 <= days_since < 4: + if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"): + percent = NotificationSettingsService.get_second_wave_discount_percent() + valid_hours = NotificationSettingsService.get_second_wave_valid_hours() + bonus_amount = settings.PRICE_30_DAYS * percent // 100 + offer = await upsert_discount_offer( + db, + user_id=user.id, + subscription_id=subscription.id, + notification_type="expired_discount_wave2", + discount_percent=percent, + bonus_amount_kopeks=bonus_amount, + valid_hours=valid_hours, + ) + success = await self._send_expired_discount_notification( + user, + subscription, + percent, + offer.expires_at, + offer.id, + "second", + bonus_amount, + ) + if success: + await record_notification(db, user.id, subscription.id, "expired_discount_wave2") + sent_wave2 += 1 + + # Third wave (N days) discount + if NotificationSettingsService.is_third_wave_enabled(): + trigger_days = NotificationSettingsService.get_third_wave_trigger_days() + if trigger_days <= days_since < trigger_days + 1: + if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"): + percent = NotificationSettingsService.get_third_wave_discount_percent() + valid_hours = NotificationSettingsService.get_third_wave_valid_hours() + bonus_amount = settings.PRICE_30_DAYS * percent // 100 + offer = await upsert_discount_offer( + db, + user_id=user.id, + subscription_id=subscription.id, + notification_type="expired_discount_wave3", + discount_percent=percent, + bonus_amount_kopeks=bonus_amount, + valid_hours=valid_hours, + ) + success = await self._send_expired_discount_notification( + user, + subscription, + percent, + offer.expires_at, + offer.id, + "third", + bonus_amount, + trigger_days=trigger_days, + ) + if success: + await record_notification(db, user.id, subscription.id, "expired_discount_wave3") + sent_wave3 += 1 + + if sent_day1 or sent_wave2 or sent_wave3: + await self._log_monitoring_event( + db, + "expired_followups_sent", + ( + "Follow-ups: 1д={0}, скидка 2-3д={1}, скидка N={2}".format( + sent_day1, + sent_wave2, + sent_wave3, + ) + ), + { + "day1": sent_day1, + "wave2": sent_wave2, + "wave3": sent_wave3, + }, + ) + + except Exception as e: + logger.error(f"Ошибка проверки напоминаний об истекшей подписке: {e}") + async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]: current_time = datetime.utcnow() threshold_date = current_time + timedelta(days=days_before) @@ -465,7 +671,7 @@ class MonitoringService: async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool: try: texts = get_texts(user.language) - + message = f""" 🎁 Тестовая подписка скоро закончится! @@ -501,7 +707,149 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}") return False - + + async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, hours: int) -> bool: + try: + texts = get_texts(user.language) + if hours >= 24: + template = texts.get( + "TRIAL_INACTIVE_24H", + ( + "⏳ Вы ещё не подключились к VPN\n\n" + "Прошли сутки с активации тестового периода, но трафик не зафиксирован." + "\n\nНажмите кнопку ниже, чтобы подключиться." + ), + ) + else: + template = texts.get( + "TRIAL_INACTIVE_1H", + ( + "⏳ Прошёл час, а подключения нет\n\n" + "Если возникли сложности с запуском — воспользуйтесь инструкциями." + ), + ) + + message = template.format( + price=settings.format_price(settings.PRICE_30_DAYS), + end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], + [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], + ]) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error(f"Ошибка отправки уведомления об отсутствии подключения пользователю {user.telegram_id}: {e}") + return False + + async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool: + try: + texts = get_texts(user.language) + template = texts.get( + "SUBSCRIPTION_EXPIRED_1D", + ( + "⛔ Подписка закончилась\n\n" + "Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис." + ), + ) + message = template.format( + end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + price=settings.format_price(settings.PRICE_30_DAYS), + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")], + [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")], + [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], + ]) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error(f"Ошибка отправки напоминания об истекшей подписке пользователю {user.telegram_id}: {e}") + return False + + async def _send_expired_discount_notification( + self, + user: User, + subscription: Subscription, + percent: int, + expires_at: datetime, + offer_id: int, + wave: str, + bonus_amount: int, + trigger_days: int = None, + ) -> bool: + try: + texts = get_texts(user.language) + + if wave == "second": + template = texts.get( + "SUBSCRIPTION_EXPIRED_SECOND_WAVE", + ( + "🔥 Скидка {percent}% на продление\n\n" + "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. " + "Предложение действует до {expires_at}." + ), + ) + else: + template = texts.get( + "SUBSCRIPTION_EXPIRED_THIRD_WAVE", + ( + "🎁 Индивидуальная скидка {percent}%\n\n" + "Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. " + "Скидка действует до {expires_at}." + ), + ) + + message = template.format( + percent=percent, + bonus=settings.format_price(bonus_amount), + expires_at=expires_at.strftime("%d.%m.%Y %H:%M"), + trigger_days=trigger_days or "", + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎁 Получить скидку", callback_data=f"claim_discount_{offer_id}")], + [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")], + [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")], + [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], + ]) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error(f"Ошибка отправки скидочного уведомления пользователю {user.telegram_id}: {e}") + return False + async def _send_autopay_success_notification(self, user: User, amount: int, days: int): try: texts = get_texts(user.language) diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py new file mode 100644 index 00000000..a19edffd --- /dev/null +++ b/app/services/notification_settings_service.py @@ -0,0 +1,249 @@ +import json +import json +import logging +from copy import deepcopy +from pathlib import Path +from typing import Any, Dict + +from app.config import settings + + +logger = logging.getLogger(__name__) + + +class NotificationSettingsService: + """Runtime-editable notification settings stored on disk.""" + + _storage_path: Path = Path("data/notification_settings.json") + _data: Dict[str, Dict[str, Any]] = {} + _loaded: bool = False + + _DEFAULTS: Dict[str, Dict[str, Any]] = { + "trial_inactive_1h": {"enabled": True}, + "trial_inactive_24h": {"enabled": True}, + "expired_1d": {"enabled": True}, + "expired_second_wave": { + "enabled": True, + "discount_percent": 10, + "valid_hours": 24, + }, + "expired_third_wave": { + "enabled": True, + "discount_percent": 20, + "valid_hours": 24, + "trigger_days": 5, + }, + } + + @classmethod + def _ensure_dir(cls) -> None: + try: + cls._storage_path.parent.mkdir(parents=True, exist_ok=True) + except Exception as exc: # pragma: no cover - filesystem guard + logger.error("Failed to create notification settings dir: %s", exc) + + @classmethod + def _load(cls) -> None: + if cls._loaded: + return + + cls._ensure_dir() + try: + if cls._storage_path.exists(): + raw = cls._storage_path.read_text(encoding="utf-8") + cls._data = json.loads(raw) if raw.strip() else {} + else: + cls._data = {} + except Exception as exc: + logger.error("Failed to load notification settings: %s", exc) + cls._data = {} + + changed = cls._apply_defaults() + if changed: + cls._save() + cls._loaded = True + + @classmethod + def _apply_defaults(cls) -> bool: + changed = False + for key, defaults in cls._DEFAULTS.items(): + current = cls._data.get(key) + if not isinstance(current, dict): + cls._data[key] = deepcopy(defaults) + changed = True + continue + + for def_key, def_value in defaults.items(): + if def_key not in current: + current[def_key] = def_value + changed = True + return changed + + @classmethod + def _save(cls) -> bool: + cls._ensure_dir() + try: + cls._storage_path.write_text( + json.dumps(cls._data, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return True + except Exception as exc: + logger.error("Failed to save notification settings: %s", exc) + return False + + @classmethod + def _get(cls, key: str) -> Dict[str, Any]: + cls._load() + value = cls._data.get(key) + if not isinstance(value, dict): + value = deepcopy(cls._DEFAULTS.get(key, {})) + cls._data[key] = value + return value + + @classmethod + def get_config(cls) -> Dict[str, Dict[str, Any]]: + cls._load() + return deepcopy(cls._data) + + @classmethod + def _set_field(cls, key: str, field: str, value: Any) -> bool: + cls._load() + section = cls._get(key) + section[field] = value + cls._data[key] = section + return cls._save() + + @classmethod + def set_enabled(cls, key: str, enabled: bool) -> bool: + return cls._set_field(key, "enabled", bool(enabled)) + + @classmethod + def is_enabled(cls, key: str) -> bool: + return bool(cls._get(key).get("enabled", True)) + + # Trial inactivity helpers + @classmethod + def is_trial_inactive_1h_enabled(cls) -> bool: + return cls.is_enabled("trial_inactive_1h") + + @classmethod + def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("trial_inactive_1h", enabled) + + @classmethod + def is_trial_inactive_24h_enabled(cls) -> bool: + return cls.is_enabled("trial_inactive_24h") + + @classmethod + def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("trial_inactive_24h", enabled) + + # Expired subscription notifications + @classmethod + def is_expired_1d_enabled(cls) -> bool: + return cls.is_enabled("expired_1d") + + @classmethod + def set_expired_1d_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("expired_1d", enabled) + + @classmethod + def is_second_wave_enabled(cls) -> bool: + return cls.is_enabled("expired_second_wave") + + @classmethod + def set_second_wave_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("expired_second_wave", enabled) + + @classmethod + def get_second_wave_discount_percent(cls) -> int: + value = cls._get("expired_second_wave").get("discount_percent", 10) + try: + return max(0, min(100, int(value))) + except (TypeError, ValueError): + return 10 + + @classmethod + def set_second_wave_discount_percent(cls, percent: int) -> bool: + try: + percent_int = max(0, min(100, int(percent))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_second_wave", "discount_percent", percent_int) + + @classmethod + def get_second_wave_valid_hours(cls) -> int: + value = cls._get("expired_second_wave").get("valid_hours", 24) + try: + return max(1, min(168, int(value))) + except (TypeError, ValueError): + return 24 + + @classmethod + def set_second_wave_valid_hours(cls, hours: int) -> bool: + try: + hours_int = max(1, min(168, int(hours))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_second_wave", "valid_hours", hours_int) + + @classmethod + def is_third_wave_enabled(cls) -> bool: + return cls.is_enabled("expired_third_wave") + + @classmethod + def set_third_wave_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("expired_third_wave", enabled) + + @classmethod + def get_third_wave_discount_percent(cls) -> int: + value = cls._get("expired_third_wave").get("discount_percent", 20) + try: + return max(0, min(100, int(value))) + except (TypeError, ValueError): + return 20 + + @classmethod + def set_third_wave_discount_percent(cls, percent: int) -> bool: + try: + percent_int = max(0, min(100, int(percent))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_third_wave", "discount_percent", percent_int) + + @classmethod + def get_third_wave_valid_hours(cls) -> int: + value = cls._get("expired_third_wave").get("valid_hours", 24) + try: + return max(1, min(168, int(value))) + except (TypeError, ValueError): + return 24 + + @classmethod + def set_third_wave_valid_hours(cls, hours: int) -> bool: + try: + hours_int = max(1, min(168, int(hours))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_third_wave", "valid_hours", hours_int) + + @classmethod + def get_third_wave_trigger_days(cls) -> int: + value = cls._get("expired_third_wave").get("trigger_days", 5) + try: + return max(2, min(60, int(value))) + except (TypeError, ValueError): + return 5 + + @classmethod + def set_third_wave_trigger_days(cls, days: int) -> bool: + try: + days_int = max(2, min(60, int(days))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_third_wave", "trigger_days", days_int) + + @classmethod + def are_notifications_globally_enabled(cls) -> bool: + return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True)) diff --git a/app/states.py b/app/states.py index 45e87e21..782fae7d 100644 --- a/app/states.py +++ b/app/states.py @@ -84,9 +84,10 @@ class AdminStates(StatesGroup): editing_device_price = State() editing_user_devices = State() editing_user_traffic = State() - + editing_rules_page = State() - + editing_notification_value = State() + confirming_sync = State() editing_server_name = State() diff --git a/locales/en.json b/locales/en.json index 1b416564..68c3adc1 100644 --- a/locales/en.json +++ b/locales/en.json @@ -129,6 +129,7 @@ "ACCESS_DENIED": "❌ Access denied", "ADMIN_MESSAGES": "📨 Broadcasts", "ADMIN_MONITORING": "🔍 Monitoring", + "ADMIN_MONITORING_SETTINGS": "🔔 Notification settings", "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", "ADMIN_PROMOCODES": "🎫 Promo codes", "ADMIN_REFERRALS": "🤝 Referral program", @@ -484,5 +485,23 @@ "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance." + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", + "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!", + "TRIAL_INACTIVE_24H": "⏳ A full day passed without activity\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Your subscription expired\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.", + "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.", + "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", + "NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.", + "NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.", + "NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):", + "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):", + "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):", + "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):" } diff --git a/locales/ru.json b/locales/ru.json index 736d38e1..9f9d87f1 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -5,6 +5,7 @@ "ADMIN_CAMPAIGNS": "📣 Рекламные кампании", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MONITORING": "🔍 Мониторинг", + "ADMIN_MONITORING_SETTINGS": "🔔 Настройки уведомлений", "ADMIN_REPORTS": "📊 Отчеты", "ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n", "ADMIN_PROMOCODES": "🎫 Промокоды", @@ -484,5 +485,23 @@ "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку." + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", + "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!", + "TRIAL_INACTIVE_24H": "⏳ Прошли сутки с начала теста\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Подписка закончилась\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", + "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", + "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", + "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", + "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", + "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", + "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):", + "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):" } From 50320ba868dd97e193e8b5fc98fec8078538d176 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:29:00 +0300 Subject: [PATCH 037/146] Revert "Implement subscription follow-up notifications and admin configuration" --- app/database/crud/discount_offer.py | 90 ----- app/database/models.py | 27 +- app/database/universal_migration.py | 95 ----- app/handlers/admin/monitoring.py | 289 -------------- app/handlers/subscription.py | 82 +--- app/keyboards/admin.py | 10 +- app/services/monitoring_service.py | 360 +----------------- app/services/notification_settings_service.py | 249 ------------ app/states.py | 5 +- locales/en.json | 21 +- locales/ru.json | 21 +- 11 files changed, 16 insertions(+), 1233 deletions(-) delete mode 100644 app/database/crud/discount_offer.py delete mode 100644 app/services/notification_settings_service.py diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py deleted file mode 100644 index eaa789ae..00000000 --- a/app/database/crud/discount_offer.py +++ /dev/null @@ -1,90 +0,0 @@ -from datetime import datetime, timedelta -from typing import Optional - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.models import DiscountOffer - - -async def upsert_discount_offer( - db: AsyncSession, - *, - user_id: int, - subscription_id: Optional[int], - notification_type: str, - discount_percent: int, - bonus_amount_kopeks: int, - valid_hours: int, -) -> DiscountOffer: - """Create or refresh a discount offer for a user.""" - - expires_at = datetime.utcnow() + timedelta(hours=valid_hours) - - result = await db.execute( - select(DiscountOffer) - .where( - DiscountOffer.user_id == user_id, - DiscountOffer.notification_type == notification_type, - DiscountOffer.is_active == True, # noqa: E712 - ) - .order_by(DiscountOffer.created_at.desc()) - ) - offer = result.scalars().first() - - if offer and offer.claimed_at is None: - offer.discount_percent = discount_percent - offer.bonus_amount_kopeks = bonus_amount_kopeks - offer.expires_at = expires_at - offer.subscription_id = subscription_id - else: - offer = DiscountOffer( - user_id=user_id, - subscription_id=subscription_id, - notification_type=notification_type, - discount_percent=discount_percent, - bonus_amount_kopeks=bonus_amount_kopeks, - expires_at=expires_at, - is_active=True, - ) - db.add(offer) - - await db.commit() - await db.refresh(offer) - return offer - - -async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]: - result = await db.execute( - select(DiscountOffer).where(DiscountOffer.id == offer_id) - ) - return result.scalar_one_or_none() - - -async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer: - offer.claimed_at = datetime.utcnow() - offer.is_active = False - await db.commit() - await db.refresh(offer) - return offer - - -async def deactivate_expired_offers(db: AsyncSession) -> int: - now = datetime.utcnow() - result = await db.execute( - select(DiscountOffer).where( - DiscountOffer.is_active == True, # noqa: E712 - DiscountOffer.expires_at < now, - ) - ) - offers = result.scalars().all() - if not offers: - return 0 - - count = 0 - for offer in offers: - offer.is_active = False - count += 1 - - await db.commit() - return count diff --git a/app/database/models.py b/app/database/models.py index 91a7a360..f9b6d8ab 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -14,7 +14,6 @@ from sqlalchemy import ( JSON, BigInteger, UniqueConstraint, - Index, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -359,7 +358,6 @@ class User(Base): subscription = relationship("Subscription", back_populates="user", uselist=False) transactions = relationship("Transaction", back_populates="user") referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user") - discount_offers = relationship("DiscountOffer", back_populates="user") lifetime_used_traffic_bytes = Column(BigInteger, default=0) auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) last_remnawave_sync = Column(DateTime, nullable=True) @@ -422,9 +420,8 @@ class Subscription(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) remnawave_short_uuid = Column(String(255), nullable=True) - + user = relationship("User", back_populates="subscription") - discount_offers = relationship("DiscountOffer", back_populates="subscription") @property def is_active(self) -> bool: @@ -768,28 +765,6 @@ class SentNotification(Base): user = relationship("User", backref="sent_notifications") subscription = relationship("Subscription", backref="sent_notifications") - -class DiscountOffer(Base): - __tablename__ = "discount_offers" - __table_args__ = ( - Index("ix_discount_offers_user_type", "user_id", "notification_type"), - ) - - id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True) - notification_type = Column(String(50), nullable=False) - discount_percent = Column(Integer, nullable=False, default=0) - bonus_amount_kopeks = Column(Integer, nullable=False, default=0) - expires_at = Column(DateTime, nullable=False) - claimed_at = Column(DateTime, nullable=True) - is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - user = relationship("User", back_populates="discount_offers") - subscription = relationship("Subscription", back_populates="discount_offers") - class BroadcastHistory(Base): __tablename__ = "broadcast_history" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 522747f0..40273ff4 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -520,94 +520,6 @@ async def create_pal24_payments_table(): logger.error(f"Ошибка создания таблицы pal24_payments: {e}") return False - -async def create_discount_offers_table(): - table_exists = await check_table_exists('discount_offers') - if table_exists: - logger.info("Таблица discount_offers уже существует") - return True - - try: - async with engine.begin() as conn: - db_type = await get_database_type() - - if db_type == 'sqlite': - await conn.execute(text(""" - CREATE TABLE discount_offers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - subscription_id INTEGER NULL, - notification_type VARCHAR(50) NOT NULL, - discount_percent INTEGER NOT NULL DEFAULT 0, - bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, - expires_at DATETIME NOT NULL, - claimed_at DATETIME NULL, - is_active BOOLEAN NOT NULL DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL - ) - """)) - await conn.execute(text(""" - CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type - ON discount_offers (user_id, notification_type) - """)) - - elif db_type == 'postgresql': - await conn.execute(text(""" - CREATE TABLE IF NOT EXISTS discount_offers ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - subscription_id INTEGER NULL REFERENCES subscriptions(id) ON DELETE SET NULL, - notification_type VARCHAR(50) NOT NULL, - discount_percent INTEGER NOT NULL DEFAULT 0, - bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, - expires_at TIMESTAMP NOT NULL, - claimed_at TIMESTAMP NULL, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """)) - await conn.execute(text(""" - CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type - ON discount_offers (user_id, notification_type) - """)) - - elif db_type == 'mysql': - await conn.execute(text(""" - CREATE TABLE IF NOT EXISTS discount_offers ( - id INTEGER PRIMARY KEY AUTO_INCREMENT, - user_id INTEGER NOT NULL, - subscription_id INTEGER NULL, - notification_type VARCHAR(50) NOT NULL, - discount_percent INTEGER NOT NULL DEFAULT 0, - bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, - expires_at DATETIME NOT NULL, - claimed_at DATETIME NULL, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT fk_discount_offers_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT fk_discount_offers_subscription FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL - ) - """)) - await conn.execute(text(""" - CREATE INDEX ix_discount_offers_user_type - ON discount_offers (user_id, notification_type) - """)) - - else: - raise ValueError(f"Unsupported database type: {db_type}") - - logger.info("✅ Таблица discount_offers успешно создана") - return True - - except Exception as e: - logger.error(f"Ошибка создания таблицы discount_offers: {e}") - return False - async def create_user_messages_table(): table_exists = await check_table_exists('user_messages') if table_exists: @@ -1555,13 +1467,6 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей Pal24 payments") - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===") - discount_created = await create_discount_offers_table() - if discount_created: - logger.info("✅ Таблица discount_offers готова") - else: - logger.warning("⚠️ Проблемы с таблицей discount_offers") - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===") user_messages_created = await create_user_messages_table() if user_messages_created: diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 2c1066b4..be876876 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -4,7 +4,6 @@ from datetime import datetime, timedelta from aiogram import Router, F from aiogram.types import Message, CallbackQuery from aiogram.filters import Command -from aiogram.fsm.context import FSMContext from app.config import settings from app.database.database import get_db @@ -13,77 +12,11 @@ from app.utils.decorators import admin_required from app.utils.pagination import paginate_list from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard from app.localization.texts import get_texts -from app.services.notification_settings_service import NotificationSettingsService -from app.states import AdminStates logger = logging.getLogger(__name__) router = Router() -def _format_toggle(enabled: bool) -> str: - return "🟢 Вкл" if enabled else "🔴 Выкл" - - -def _build_notification_settings_view(language: str): - texts = get_texts(language) - config = NotificationSettingsService.get_config() - - second_percent = NotificationSettingsService.get_second_wave_discount_percent() - second_hours = NotificationSettingsService.get_second_wave_valid_hours() - third_percent = NotificationSettingsService.get_third_wave_discount_percent() - third_hours = NotificationSettingsService.get_third_wave_valid_hours() - third_days = NotificationSettingsService.get_third_wave_trigger_days() - - trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True)) - trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True)) - expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True)) - second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True)) - third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True)) - - summary_text = ( - "🔔 Уведомления пользователям\n\n" - f"• 1 час после триала: {trial_1h_status}\n" - f"• 24 часа после триала: {trial_24h_status}\n" - f"• 1 день после истечения: {expired_1d_status}\n" - f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n" - f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}" - ) - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")], - [InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")], - [InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")], - [InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")], - [InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")], - [InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")], - [InlineKeyboardButton(text=f"{third_wave_status} • {third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")], - [InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")], - [InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")], - [InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")], - ]) - - return summary_text, keyboard - - -async def _render_notification_settings(callback: CallbackQuery) -> None: - language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE) - text, keyboard = _build_notification_settings_view(language) - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) - - -async def _render_notification_settings_for_state(bot, chat_id: int, message_id: int, language: str) -> None: - text, keyboard = _build_notification_settings_view(language) - await bot.edit_message_text( - text, - chat_id, - message_id, - parse_mode="HTML", - reply_markup=keyboard, - ) - @router.callback_query(F.data == "admin_monitoring") @admin_required async def admin_monitoring_menu(callback: CallbackQuery): @@ -119,180 +52,6 @@ async def admin_monitoring_menu(callback: CallbackQuery): await callback.answer("❌ Ошибка получения данных", show_alert=True) -@router.callback_query(F.data == "admin_mon_settings") -@admin_required -async def admin_monitoring_settings(callback: CallbackQuery): - try: - language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE - global_status = "🟢 Включены" if NotificationSettingsService.are_notifications_globally_enabled() else "🔴 Отключены" - second_percent = NotificationSettingsService.get_second_wave_discount_percent() - third_percent = NotificationSettingsService.get_third_wave_discount_percent() - third_days = NotificationSettingsService.get_third_wave_trigger_days() - - text = ( - "⚙️ Настройки мониторинга\n\n" - f"🔔 Уведомления пользователям: {global_status}\n" - f"• Скидка 2-3 дня: {second_percent}%\n" - f"• Скидка после {third_days} дней: {third_percent}%\n\n" - "Выберите раздел для настройки." - ) - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🔔 Уведомления пользователям", callback_data="admin_mon_notify_settings")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")], - ]) - - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) - - except Exception as e: - logger.error(f"Ошибка отображения настроек мониторинга: {e}") - await callback.answer("❌ Не удалось открыть настройки", show_alert=True) - - -@router.callback_query(F.data == "admin_mon_notify_settings") -@admin_required -async def admin_notify_settings(callback: CallbackQuery): - try: - await _render_notification_settings(callback) - except Exception as e: - logger.error(f"Ошибка отображения настроек уведомлений: {e}") - await callback.answer("❌ Не удалось загрузить настройки", show_alert=True) - - -@router.callback_query(F.data == "admin_mon_notify_toggle_trial_1h") -@admin_required -async def toggle_trial_1h_notification(callback: CallbackQuery): - enabled = NotificationSettingsService.is_trial_inactive_1h_enabled() - NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") - await _render_notification_settings(callback) - - -@router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h") -@admin_required -async def toggle_trial_24h_notification(callback: CallbackQuery): - enabled = NotificationSettingsService.is_trial_inactive_24h_enabled() - NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") - await _render_notification_settings(callback) - - -@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d") -@admin_required -async def toggle_expired_1d_notification(callback: CallbackQuery): - enabled = NotificationSettingsService.is_expired_1d_enabled() - NotificationSettingsService.set_expired_1d_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") - await _render_notification_settings(callback) - - -@router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d") -@admin_required -async def toggle_second_wave_notification(callback: CallbackQuery): - enabled = NotificationSettingsService.is_second_wave_enabled() - NotificationSettingsService.set_second_wave_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") - await _render_notification_settings(callback) - - -@router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd") -@admin_required -async def toggle_third_wave_notification(callback: CallbackQuery): - enabled = NotificationSettingsService.is_third_wave_enabled() - NotificationSettingsService.set_third_wave_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") - await _render_notification_settings(callback) - - -async def _start_notification_value_edit( - callback: CallbackQuery, - state: FSMContext, - setting_key: str, - field: str, - prompt_key: str, - default_prompt: str, -): - language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE - await state.set_state(AdminStates.editing_notification_value) - await state.update_data( - notification_setting_key=setting_key, - notification_setting_field=field, - settings_message_chat=callback.message.chat.id, - settings_message_id=callback.message.message_id, - settings_language=language, - ) - texts = get_texts(language) - await callback.answer() - await callback.message.answer(texts.get(prompt_key, default_prompt)) - - -@router.callback_query(F.data == "admin_mon_notify_edit_2d_percent") -@admin_required -async def edit_second_wave_percent(callback: CallbackQuery, state: FSMContext): - await _start_notification_value_edit( - callback, - state, - "expired_second_wave", - "percent", - "NOTIFY_PROMPT_SECOND_PERCENT", - "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", - ) - - -@router.callback_query(F.data == "admin_mon_notify_edit_2d_hours") -@admin_required -async def edit_second_wave_hours(callback: CallbackQuery, state: FSMContext): - await _start_notification_value_edit( - callback, - state, - "expired_second_wave", - "hours", - "NOTIFY_PROMPT_SECOND_HOURS", - "Введите количество часов действия скидки (1-168):", - ) - - -@router.callback_query(F.data == "admin_mon_notify_edit_nd_percent") -@admin_required -async def edit_third_wave_percent(callback: CallbackQuery, state: FSMContext): - await _start_notification_value_edit( - callback, - state, - "expired_third_wave", - "percent", - "NOTIFY_PROMPT_THIRD_PERCENT", - "Введите новый процент скидки для позднего предложения (0-100):", - ) - - -@router.callback_query(F.data == "admin_mon_notify_edit_nd_hours") -@admin_required -async def edit_third_wave_hours(callback: CallbackQuery, state: FSMContext): - await _start_notification_value_edit( - callback, - state, - "expired_third_wave", - "hours", - "NOTIFY_PROMPT_THIRD_HOURS", - "Введите количество часов действия скидки (1-168):", - ) - - -@router.callback_query(F.data == "admin_mon_notify_edit_nd_threshold") -@admin_required -async def edit_third_wave_threshold(callback: CallbackQuery, state: FSMContext): - await _start_notification_value_edit( - callback, - state, - "expired_third_wave", - "trigger", - "NOTIFY_PROMPT_THIRD_DAYS", - "Через сколько дней после истечения отправлять предложение? (минимум 2):", - ) - - @router.callback_query(F.data == "admin_mon_start") @admin_required async def start_monitoring_callback(callback: CallbackQuery): @@ -607,53 +366,5 @@ async def monitoring_command(message: Message): await message.answer(f"❌ Ошибка: {str(e)}") -@router.message(AdminStates.editing_notification_value) -async def process_notification_value_input(message: Message, state: FSMContext): - data = await state.get_data() - if not data: - await state.clear() - await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.") - return - - raw_value = (message.text or "").strip() - try: - value = int(raw_value) - except (TypeError, ValueError): - language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE - texts = get_texts(language) - await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Введите целое число.")) - return - - key = data.get("notification_setting_key") - field = data.get("notification_setting_field") - language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE - texts = get_texts(language) - - success = False - if key == "expired_second_wave" and field == "percent": - success = NotificationSettingsService.set_second_wave_discount_percent(value) - elif key == "expired_second_wave" and field == "hours": - success = NotificationSettingsService.set_second_wave_valid_hours(value) - elif key == "expired_third_wave" and field == "percent": - success = NotificationSettingsService.set_third_wave_discount_percent(value) - elif key == "expired_third_wave" and field == "hours": - success = NotificationSettingsService.set_third_wave_valid_hours(value) - elif key == "expired_third_wave" and field == "trigger": - success = NotificationSettingsService.set_third_wave_trigger_days(value) - - if not success: - await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова.")) - return - - await message.answer(texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены.")) - - chat_id = data.get("settings_message_chat") - message_id = data.get("settings_message_id") - if chat_id and message_id: - await _render_notification_settings_for_state(message.bot, chat_id, message_id, language) - - await state.clear() - - def register_handlers(dp): dp.include_router(router) \ No newline at end of file diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 3eeee497..3f0c182a 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -17,13 +17,12 @@ from app.database.crud.subscription import ( add_subscription_squad, update_subscription_autopay, add_subscription_servers ) -from app.database.crud.user import subtract_user_balance, add_user_balance +from app.database.crud.user import subtract_user_balance from app.database.crud.transaction import create_transaction, get_user_transactions from app.database.models import ( - User, TransactionType, SubscriptionStatus, - SubscriptionServer, Subscription + User, TransactionType, SubscriptionStatus, + SubscriptionServer, Subscription ) -from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed from app.keyboards.inline import ( get_subscription_keyboard, get_trial_keyboard, get_subscription_period_keyboard, get_traffic_packages_keyboard, @@ -4069,76 +4068,6 @@ async def handle_connect_subscription( await callback.answer() -async def claim_discount_offer( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - - try: - offer_id = int(callback.data.split("_")[-1]) - except (ValueError, AttributeError): - await callback.answer( - texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), - show_alert=True, - ) - return - - offer = await get_offer_by_id(db, offer_id) - if not offer or offer.user_id != db_user.id: - await callback.answer( - texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), - show_alert=True, - ) - return - - now = datetime.utcnow() - if offer.claimed_at is not None: - await callback.answer( - texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"), - show_alert=True, - ) - return - - if not offer.is_active or offer.expires_at <= now: - offer.is_active = False - await db.commit() - await callback.answer( - texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"), - show_alert=True, - ) - return - - bonus_amount = offer.bonus_amount_kopeks or 0 - if bonus_amount > 0: - success = await add_user_balance( - db, - db_user, - bonus_amount, - texts.get("DISCOUNT_BONUS_DESCRIPTION", "Скидка за продление подписки"), - ) - if not success: - await callback.answer( - texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось начислить скидку. Попробуйте позже."), - show_alert=True, - ) - return - - await mark_offer_claimed(db, offer) - - success_message = texts.get( - "DISCOUNT_CLAIM_SUCCESS", - "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", - ).format( - percent=offer.discount_percent, - amount=settings.format_price(bonus_amount), - ) - - await callback.answer("✅ Скидка активирована!", show_alert=True) - await callback.message.answer(success_message) - - async def handle_device_guide( callback: types.CallbackQuery, db_user: User, @@ -5034,11 +4963,6 @@ def register_handlers(dp: Dispatcher): F.data == "countries_apply" ) - dp.callback_query.register( - claim_discount_offer, - F.data.startswith("claim_discount_") - ) - dp.callback_query.register( handle_connect_subscription, F.data == "subscription_connect" diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index c6dba961..8219147b 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -93,17 +93,14 @@ def get_admin_support_submenu_keyboard(language: str = "ru") -> InlineKeyboardMa def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave"), InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring") ], [ - InlineKeyboardButton(text=texts.t("ADMIN_MONITORING_SETTINGS", "🔔 Настройки уведомлений"), callback_data="admin_mon_settings"), - InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules") - ], - [ + InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"), InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel") ], [ @@ -785,9 +782,6 @@ def get_monitoring_keyboard() -> InlineKeyboardMarkup: InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"), InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics") ], - [ - InlineKeyboardButton(text="⚙️ Настройки уведомлений", callback_data="admin_mon_settings") - ], [ InlineKeyboardButton(text="⬅️ Назад в админку", callback_data="admin_panel") ] diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 337e18f8..a190aec4 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -21,15 +21,10 @@ from app.database.crud.notification import ( notification_sent, record_notification, ) -from app.database.crud.discount_offer import ( - upsert_discount_offer, - deactivate_expired_offers, -) from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus from app.services.subscription_service import SubscriptionService from app.services.payment_service import PaymentService from app.localization.texts import get_texts -from app.services.notification_settings_service import NotificationSettingsService from app.external.remnawave_api import ( RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError @@ -85,16 +80,10 @@ class MonitoringService: async for db in get_db(): try: await self._cleanup_notification_cache() - - expired_offers = await deactivate_expired_offers(db) - if expired_offers: - logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений") - + await self._check_expired_subscriptions(db) await self._check_expiring_subscriptions(db) - await self._check_trial_expiring_soon(db) - await self._check_trial_inactivity_notifications(db) - await self._check_expired_subscription_followups(db) + await self._check_trial_expiring_soon(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) await self._sync_with_remnawave(db) @@ -261,7 +250,7 @@ class MonitoringService: async def _check_trial_expiring_soon(self, db: AsyncSession): try: threshold_time = datetime.utcnow() + timedelta(hours=2) - + result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -299,202 +288,7 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}") - - async def _check_trial_inactivity_notifications(self, db: AsyncSession): - if not NotificationSettingsService.are_notifications_globally_enabled(): - return - if not self.bot: - return - - try: - now = datetime.utcnow() - one_hour_ago = now - timedelta(hours=1) - - result = await db.execute( - select(Subscription) - .options(selectinload(Subscription.user)) - .where( - and_( - Subscription.status == SubscriptionStatus.ACTIVE.value, - Subscription.is_trial == True, - Subscription.start_date.isnot(None), - Subscription.start_date <= one_hour_ago, - Subscription.end_date > now, - ) - ) - ) - - subscriptions = result.scalars().all() - sent_1h = 0 - sent_24h = 0 - - for subscription in subscriptions: - user = subscription.user - if not user: - continue - - if (subscription.traffic_used_gb or 0) > 0: - continue - - start_date = subscription.start_date - if not start_date: - continue - - time_since_start = now - start_date - - if (NotificationSettingsService.is_trial_inactive_1h_enabled() - and timedelta(hours=1) <= time_since_start < timedelta(hours=24)): - if not await notification_sent(db, user.id, subscription.id, "trial_inactive_1h"): - success = await self._send_trial_inactive_notification(user, subscription, 1) - if success: - await record_notification(db, user.id, subscription.id, "trial_inactive_1h") - sent_1h += 1 - - if NotificationSettingsService.is_trial_inactive_24h_enabled() and time_since_start >= timedelta(hours=24): - if not await notification_sent(db, user.id, subscription.id, "trial_inactive_24h"): - success = await self._send_trial_inactive_notification(user, subscription, 24) - if success: - await record_notification(db, user.id, subscription.id, "trial_inactive_24h") - sent_24h += 1 - - if sent_1h or sent_24h: - await self._log_monitoring_event( - db, - "trial_inactivity_notifications", - f"Отправлено {sent_1h} уведомлений спустя 1 час и {sent_24h} спустя 24 часа", - {"sent_1h": sent_1h, "sent_24h": sent_24h}, - ) - - except Exception as e: - logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}") - - async def _check_expired_subscription_followups(self, db: AsyncSession): - if not NotificationSettingsService.are_notifications_globally_enabled(): - return - if not self.bot: - return - - try: - now = datetime.utcnow() - - result = await db.execute( - select(Subscription) - .options(selectinload(Subscription.user)) - .where( - and_( - Subscription.is_trial == False, - Subscription.end_date <= now, - ) - ) - ) - - subscriptions = result.scalars().all() - sent_day1 = 0 - sent_wave2 = 0 - sent_wave3 = 0 - - for subscription in subscriptions: - user = subscription.user - if not user: - continue - - if subscription.end_date is None: - continue - - time_since_end = now - subscription.end_date - if time_since_end.total_seconds() < 0: - continue - - days_since = time_since_end.total_seconds() / 86400 - - # Day 1 reminder - if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2: - if not await notification_sent(db, user.id, subscription.id, "expired_1d"): - success = await self._send_expired_day1_notification(user, subscription) - if success: - await record_notification(db, user.id, subscription.id, "expired_1d") - sent_day1 += 1 - - # Second wave (2-3 days) discount - if NotificationSettingsService.is_second_wave_enabled() and 2 <= days_since < 4: - if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"): - percent = NotificationSettingsService.get_second_wave_discount_percent() - valid_hours = NotificationSettingsService.get_second_wave_valid_hours() - bonus_amount = settings.PRICE_30_DAYS * percent // 100 - offer = await upsert_discount_offer( - db, - user_id=user.id, - subscription_id=subscription.id, - notification_type="expired_discount_wave2", - discount_percent=percent, - bonus_amount_kopeks=bonus_amount, - valid_hours=valid_hours, - ) - success = await self._send_expired_discount_notification( - user, - subscription, - percent, - offer.expires_at, - offer.id, - "second", - bonus_amount, - ) - if success: - await record_notification(db, user.id, subscription.id, "expired_discount_wave2") - sent_wave2 += 1 - - # Third wave (N days) discount - if NotificationSettingsService.is_third_wave_enabled(): - trigger_days = NotificationSettingsService.get_third_wave_trigger_days() - if trigger_days <= days_since < trigger_days + 1: - if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"): - percent = NotificationSettingsService.get_third_wave_discount_percent() - valid_hours = NotificationSettingsService.get_third_wave_valid_hours() - bonus_amount = settings.PRICE_30_DAYS * percent // 100 - offer = await upsert_discount_offer( - db, - user_id=user.id, - subscription_id=subscription.id, - notification_type="expired_discount_wave3", - discount_percent=percent, - bonus_amount_kopeks=bonus_amount, - valid_hours=valid_hours, - ) - success = await self._send_expired_discount_notification( - user, - subscription, - percent, - offer.expires_at, - offer.id, - "third", - bonus_amount, - trigger_days=trigger_days, - ) - if success: - await record_notification(db, user.id, subscription.id, "expired_discount_wave3") - sent_wave3 += 1 - - if sent_day1 or sent_wave2 or sent_wave3: - await self._log_monitoring_event( - db, - "expired_followups_sent", - ( - "Follow-ups: 1д={0}, скидка 2-3д={1}, скидка N={2}".format( - sent_day1, - sent_wave2, - sent_wave3, - ) - ), - { - "day1": sent_day1, - "wave2": sent_wave2, - "wave3": sent_wave3, - }, - ) - - except Exception as e: - logger.error(f"Ошибка проверки напоминаний об истекшей подписке: {e}") - + async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]: current_time = datetime.utcnow() threshold_date = current_time + timedelta(days=days_before) @@ -671,7 +465,7 @@ class MonitoringService: async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool: try: texts = get_texts(user.language) - + message = f""" 🎁 Тестовая подписка скоро закончится! @@ -707,149 +501,7 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}") return False - - async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, hours: int) -> bool: - try: - texts = get_texts(user.language) - if hours >= 24: - template = texts.get( - "TRIAL_INACTIVE_24H", - ( - "⏳ Вы ещё не подключились к VPN\n\n" - "Прошли сутки с активации тестового периода, но трафик не зафиксирован." - "\n\nНажмите кнопку ниже, чтобы подключиться." - ), - ) - else: - template = texts.get( - "TRIAL_INACTIVE_1H", - ( - "⏳ Прошёл час, а подключения нет\n\n" - "Если возникли сложности с запуском — воспользуйтесь инструкциями." - ), - ) - - message = template.format( - price=settings.format_price(settings.PRICE_30_DAYS), - end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), - ) - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], - [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], - [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], - ]) - - await self.bot.send_message( - user.telegram_id, - message, - parse_mode="HTML", - reply_markup=keyboard, - ) - return True - - except Exception as e: - logger.error(f"Ошибка отправки уведомления об отсутствии подключения пользователю {user.telegram_id}: {e}") - return False - - async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool: - try: - texts = get_texts(user.language) - template = texts.get( - "SUBSCRIPTION_EXPIRED_1D", - ( - "⛔ Подписка закончилась\n\n" - "Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис." - ), - ) - message = template.format( - end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), - price=settings.format_price(settings.PRICE_30_DAYS), - ) - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")], - [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")], - [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], - ]) - - await self.bot.send_message( - user.telegram_id, - message, - parse_mode="HTML", - reply_markup=keyboard, - ) - return True - - except Exception as e: - logger.error(f"Ошибка отправки напоминания об истекшей подписке пользователю {user.telegram_id}: {e}") - return False - - async def _send_expired_discount_notification( - self, - user: User, - subscription: Subscription, - percent: int, - expires_at: datetime, - offer_id: int, - wave: str, - bonus_amount: int, - trigger_days: int = None, - ) -> bool: - try: - texts = get_texts(user.language) - - if wave == "second": - template = texts.get( - "SUBSCRIPTION_EXPIRED_SECOND_WAVE", - ( - "🔥 Скидка {percent}% на продление\n\n" - "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. " - "Предложение действует до {expires_at}." - ), - ) - else: - template = texts.get( - "SUBSCRIPTION_EXPIRED_THIRD_WAVE", - ( - "🎁 Индивидуальная скидка {percent}%\n\n" - "Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. " - "Скидка действует до {expires_at}." - ), - ) - - message = template.format( - percent=percent, - bonus=settings.format_price(bonus_amount), - expires_at=expires_at.strftime("%d.%m.%Y %H:%M"), - trigger_days=trigger_days or "", - ) - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🎁 Получить скидку", callback_data=f"claim_discount_{offer_id}")], - [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")], - [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")], - [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], - ]) - - await self.bot.send_message( - user.telegram_id, - message, - parse_mode="HTML", - reply_markup=keyboard, - ) - return True - - except Exception as e: - logger.error(f"Ошибка отправки скидочного уведомления пользователю {user.telegram_id}: {e}") - return False - + async def _send_autopay_success_notification(self, user: User, amount: int, days: int): try: texts = get_texts(user.language) diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py deleted file mode 100644 index a19edffd..00000000 --- a/app/services/notification_settings_service.py +++ /dev/null @@ -1,249 +0,0 @@ -import json -import json -import logging -from copy import deepcopy -from pathlib import Path -from typing import Any, Dict - -from app.config import settings - - -logger = logging.getLogger(__name__) - - -class NotificationSettingsService: - """Runtime-editable notification settings stored on disk.""" - - _storage_path: Path = Path("data/notification_settings.json") - _data: Dict[str, Dict[str, Any]] = {} - _loaded: bool = False - - _DEFAULTS: Dict[str, Dict[str, Any]] = { - "trial_inactive_1h": {"enabled": True}, - "trial_inactive_24h": {"enabled": True}, - "expired_1d": {"enabled": True}, - "expired_second_wave": { - "enabled": True, - "discount_percent": 10, - "valid_hours": 24, - }, - "expired_third_wave": { - "enabled": True, - "discount_percent": 20, - "valid_hours": 24, - "trigger_days": 5, - }, - } - - @classmethod - def _ensure_dir(cls) -> None: - try: - cls._storage_path.parent.mkdir(parents=True, exist_ok=True) - except Exception as exc: # pragma: no cover - filesystem guard - logger.error("Failed to create notification settings dir: %s", exc) - - @classmethod - def _load(cls) -> None: - if cls._loaded: - return - - cls._ensure_dir() - try: - if cls._storage_path.exists(): - raw = cls._storage_path.read_text(encoding="utf-8") - cls._data = json.loads(raw) if raw.strip() else {} - else: - cls._data = {} - except Exception as exc: - logger.error("Failed to load notification settings: %s", exc) - cls._data = {} - - changed = cls._apply_defaults() - if changed: - cls._save() - cls._loaded = True - - @classmethod - def _apply_defaults(cls) -> bool: - changed = False - for key, defaults in cls._DEFAULTS.items(): - current = cls._data.get(key) - if not isinstance(current, dict): - cls._data[key] = deepcopy(defaults) - changed = True - continue - - for def_key, def_value in defaults.items(): - if def_key not in current: - current[def_key] = def_value - changed = True - return changed - - @classmethod - def _save(cls) -> bool: - cls._ensure_dir() - try: - cls._storage_path.write_text( - json.dumps(cls._data, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - return True - except Exception as exc: - logger.error("Failed to save notification settings: %s", exc) - return False - - @classmethod - def _get(cls, key: str) -> Dict[str, Any]: - cls._load() - value = cls._data.get(key) - if not isinstance(value, dict): - value = deepcopy(cls._DEFAULTS.get(key, {})) - cls._data[key] = value - return value - - @classmethod - def get_config(cls) -> Dict[str, Dict[str, Any]]: - cls._load() - return deepcopy(cls._data) - - @classmethod - def _set_field(cls, key: str, field: str, value: Any) -> bool: - cls._load() - section = cls._get(key) - section[field] = value - cls._data[key] = section - return cls._save() - - @classmethod - def set_enabled(cls, key: str, enabled: bool) -> bool: - return cls._set_field(key, "enabled", bool(enabled)) - - @classmethod - def is_enabled(cls, key: str) -> bool: - return bool(cls._get(key).get("enabled", True)) - - # Trial inactivity helpers - @classmethod - def is_trial_inactive_1h_enabled(cls) -> bool: - return cls.is_enabled("trial_inactive_1h") - - @classmethod - def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("trial_inactive_1h", enabled) - - @classmethod - def is_trial_inactive_24h_enabled(cls) -> bool: - return cls.is_enabled("trial_inactive_24h") - - @classmethod - def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("trial_inactive_24h", enabled) - - # Expired subscription notifications - @classmethod - def is_expired_1d_enabled(cls) -> bool: - return cls.is_enabled("expired_1d") - - @classmethod - def set_expired_1d_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("expired_1d", enabled) - - @classmethod - def is_second_wave_enabled(cls) -> bool: - return cls.is_enabled("expired_second_wave") - - @classmethod - def set_second_wave_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("expired_second_wave", enabled) - - @classmethod - def get_second_wave_discount_percent(cls) -> int: - value = cls._get("expired_second_wave").get("discount_percent", 10) - try: - return max(0, min(100, int(value))) - except (TypeError, ValueError): - return 10 - - @classmethod - def set_second_wave_discount_percent(cls, percent: int) -> bool: - try: - percent_int = max(0, min(100, int(percent))) - except (TypeError, ValueError): - return False - return cls._set_field("expired_second_wave", "discount_percent", percent_int) - - @classmethod - def get_second_wave_valid_hours(cls) -> int: - value = cls._get("expired_second_wave").get("valid_hours", 24) - try: - return max(1, min(168, int(value))) - except (TypeError, ValueError): - return 24 - - @classmethod - def set_second_wave_valid_hours(cls, hours: int) -> bool: - try: - hours_int = max(1, min(168, int(hours))) - except (TypeError, ValueError): - return False - return cls._set_field("expired_second_wave", "valid_hours", hours_int) - - @classmethod - def is_third_wave_enabled(cls) -> bool: - return cls.is_enabled("expired_third_wave") - - @classmethod - def set_third_wave_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("expired_third_wave", enabled) - - @classmethod - def get_third_wave_discount_percent(cls) -> int: - value = cls._get("expired_third_wave").get("discount_percent", 20) - try: - return max(0, min(100, int(value))) - except (TypeError, ValueError): - return 20 - - @classmethod - def set_third_wave_discount_percent(cls, percent: int) -> bool: - try: - percent_int = max(0, min(100, int(percent))) - except (TypeError, ValueError): - return False - return cls._set_field("expired_third_wave", "discount_percent", percent_int) - - @classmethod - def get_third_wave_valid_hours(cls) -> int: - value = cls._get("expired_third_wave").get("valid_hours", 24) - try: - return max(1, min(168, int(value))) - except (TypeError, ValueError): - return 24 - - @classmethod - def set_third_wave_valid_hours(cls, hours: int) -> bool: - try: - hours_int = max(1, min(168, int(hours))) - except (TypeError, ValueError): - return False - return cls._set_field("expired_third_wave", "valid_hours", hours_int) - - @classmethod - def get_third_wave_trigger_days(cls) -> int: - value = cls._get("expired_third_wave").get("trigger_days", 5) - try: - return max(2, min(60, int(value))) - except (TypeError, ValueError): - return 5 - - @classmethod - def set_third_wave_trigger_days(cls, days: int) -> bool: - try: - days_int = max(2, min(60, int(days))) - except (TypeError, ValueError): - return False - return cls._set_field("expired_third_wave", "trigger_days", days_int) - - @classmethod - def are_notifications_globally_enabled(cls) -> bool: - return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True)) diff --git a/app/states.py b/app/states.py index 782fae7d..45e87e21 100644 --- a/app/states.py +++ b/app/states.py @@ -84,10 +84,9 @@ class AdminStates(StatesGroup): editing_device_price = State() editing_user_devices = State() editing_user_traffic = State() - + editing_rules_page = State() - editing_notification_value = State() - + confirming_sync = State() editing_server_name = State() diff --git a/locales/en.json b/locales/en.json index 68c3adc1..1b416564 100644 --- a/locales/en.json +++ b/locales/en.json @@ -129,7 +129,6 @@ "ACCESS_DENIED": "❌ Access denied", "ADMIN_MESSAGES": "📨 Broadcasts", "ADMIN_MONITORING": "🔍 Monitoring", - "ADMIN_MONITORING_SETTINGS": "🔔 Notification settings", "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", "ADMIN_PROMOCODES": "🎫 Promo codes", "ADMIN_REFERRALS": "🤝 Referral program", @@ -485,23 +484,5 @@ "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", - "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!", - "TRIAL_INACTIVE_24H": "⏳ A full day passed without activity\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!", - "SUBSCRIPTION_EXPIRED_1D": "⛔ Your subscription expired\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}", - "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.", - "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.", - "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.", - "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.", - "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.", - "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.", - "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", - "NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.", - "NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.", - "NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):", - "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):", - "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):", - "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):", - "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):" + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance." } diff --git a/locales/ru.json b/locales/ru.json index 9f9d87f1..736d38e1 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -5,7 +5,6 @@ "ADMIN_CAMPAIGNS": "📣 Рекламные кампании", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MONITORING": "🔍 Мониторинг", - "ADMIN_MONITORING_SETTINGS": "🔔 Настройки уведомлений", "ADMIN_REPORTS": "📊 Отчеты", "ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n", "ADMIN_PROMOCODES": "🎫 Промокоды", @@ -485,23 +484,5 @@ "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", - "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!", - "TRIAL_INACTIVE_24H": "⏳ Прошли сутки с начала теста\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!", - "SUBSCRIPTION_EXPIRED_1D": "⛔ Подписка закончилась\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}", - "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.", - "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", - "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", - "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", - "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", - "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", - "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", - "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", - "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", - "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", - "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):", - "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):", - "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):", - "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):" + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку." } From f545c0d7ad47df2902518d049a5f13362252baea Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:29:39 +0300 Subject: [PATCH 038/146] Add monitoring settings shortcut to admin settings submenu --- app/database/crud/discount_offer.py | 90 +++++ app/database/models.py | 27 +- app/database/universal_migration.py | 95 +++++ app/handlers/admin/monitoring.py | 289 ++++++++++++++ app/handlers/subscription.py | 82 +++- app/keyboards/admin.py | 6 + app/services/monitoring_service.py | 360 +++++++++++++++++- app/services/notification_settings_service.py | 249 ++++++++++++ app/states.py | 5 +- locales/en.json | 21 +- locales/ru.json | 21 +- 11 files changed, 1231 insertions(+), 14 deletions(-) create mode 100644 app/database/crud/discount_offer.py create mode 100644 app/services/notification_settings_service.py diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py new file mode 100644 index 00000000..eaa789ae --- /dev/null +++ b/app/database/crud/discount_offer.py @@ -0,0 +1,90 @@ +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import DiscountOffer + + +async def upsert_discount_offer( + db: AsyncSession, + *, + user_id: int, + subscription_id: Optional[int], + notification_type: str, + discount_percent: int, + bonus_amount_kopeks: int, + valid_hours: int, +) -> DiscountOffer: + """Create or refresh a discount offer for a user.""" + + expires_at = datetime.utcnow() + timedelta(hours=valid_hours) + + result = await db.execute( + select(DiscountOffer) + .where( + DiscountOffer.user_id == user_id, + DiscountOffer.notification_type == notification_type, + DiscountOffer.is_active == True, # noqa: E712 + ) + .order_by(DiscountOffer.created_at.desc()) + ) + offer = result.scalars().first() + + if offer and offer.claimed_at is None: + offer.discount_percent = discount_percent + offer.bonus_amount_kopeks = bonus_amount_kopeks + offer.expires_at = expires_at + offer.subscription_id = subscription_id + else: + offer = DiscountOffer( + user_id=user_id, + subscription_id=subscription_id, + notification_type=notification_type, + discount_percent=discount_percent, + bonus_amount_kopeks=bonus_amount_kopeks, + expires_at=expires_at, + is_active=True, + ) + db.add(offer) + + await db.commit() + await db.refresh(offer) + return offer + + +async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]: + result = await db.execute( + select(DiscountOffer).where(DiscountOffer.id == offer_id) + ) + return result.scalar_one_or_none() + + +async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer: + offer.claimed_at = datetime.utcnow() + offer.is_active = False + await db.commit() + await db.refresh(offer) + return offer + + +async def deactivate_expired_offers(db: AsyncSession) -> int: + now = datetime.utcnow() + result = await db.execute( + select(DiscountOffer).where( + DiscountOffer.is_active == True, # noqa: E712 + DiscountOffer.expires_at < now, + ) + ) + offers = result.scalars().all() + if not offers: + return 0 + + count = 0 + for offer in offers: + offer.is_active = False + count += 1 + + await db.commit() + return count diff --git a/app/database/models.py b/app/database/models.py index f9b6d8ab..91a7a360 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -14,6 +14,7 @@ from sqlalchemy import ( JSON, BigInteger, UniqueConstraint, + Index, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -358,6 +359,7 @@ class User(Base): subscription = relationship("Subscription", back_populates="user", uselist=False) transactions = relationship("Transaction", back_populates="user") referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user") + discount_offers = relationship("DiscountOffer", back_populates="user") lifetime_used_traffic_bytes = Column(BigInteger, default=0) auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) last_remnawave_sync = Column(DateTime, nullable=True) @@ -420,8 +422,9 @@ class Subscription(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) remnawave_short_uuid = Column(String(255), nullable=True) - + user = relationship("User", back_populates="subscription") + discount_offers = relationship("DiscountOffer", back_populates="subscription") @property def is_active(self) -> bool: @@ -765,6 +768,28 @@ class SentNotification(Base): user = relationship("User", backref="sent_notifications") subscription = relationship("Subscription", backref="sent_notifications") + +class DiscountOffer(Base): + __tablename__ = "discount_offers" + __table_args__ = ( + Index("ix_discount_offers_user_type", "user_id", "notification_type"), + ) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True) + notification_type = Column(String(50), nullable=False) + discount_percent = Column(Integer, nullable=False, default=0) + bonus_amount_kopeks = Column(Integer, nullable=False, default=0) + expires_at = Column(DateTime, nullable=False) + claimed_at = Column(DateTime, nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + user = relationship("User", back_populates="discount_offers") + subscription = relationship("Subscription", back_populates="discount_offers") + class BroadcastHistory(Base): __tablename__ = "broadcast_history" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 40273ff4..522747f0 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -520,6 +520,94 @@ async def create_pal24_payments_table(): logger.error(f"Ошибка создания таблицы pal24_payments: {e}") return False + +async def create_discount_offers_table(): + table_exists = await check_table_exists('discount_offers') + if table_exists: + logger.info("Таблица discount_offers уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text(""" + CREATE TABLE discount_offers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + subscription_id INTEGER NULL, + notification_type VARCHAR(50) NOT NULL, + discount_percent INTEGER NOT NULL DEFAULT 0, + bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, + expires_at DATETIME NOT NULL, + claimed_at DATETIME NULL, + is_active BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL + ) + """)) + await conn.execute(text(""" + CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type + ON discount_offers (user_id, notification_type) + """)) + + elif db_type == 'postgresql': + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS discount_offers ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subscription_id INTEGER NULL REFERENCES subscriptions(id) ON DELETE SET NULL, + notification_type VARCHAR(50) NOT NULL, + discount_percent INTEGER NOT NULL DEFAULT 0, + bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMP NOT NULL, + claimed_at TIMESTAMP NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """)) + await conn.execute(text(""" + CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type + ON discount_offers (user_id, notification_type) + """)) + + elif db_type == 'mysql': + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS discount_offers ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + user_id INTEGER NOT NULL, + subscription_id INTEGER NULL, + notification_type VARCHAR(50) NOT NULL, + discount_percent INTEGER NOT NULL DEFAULT 0, + bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, + expires_at DATETIME NOT NULL, + claimed_at DATETIME NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_discount_offers_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_discount_offers_subscription FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL + ) + """)) + await conn.execute(text(""" + CREATE INDEX ix_discount_offers_user_type + ON discount_offers (user_id, notification_type) + """)) + + else: + raise ValueError(f"Unsupported database type: {db_type}") + + logger.info("✅ Таблица discount_offers успешно создана") + return True + + except Exception as e: + logger.error(f"Ошибка создания таблицы discount_offers: {e}") + return False + async def create_user_messages_table(): table_exists = await check_table_exists('user_messages') if table_exists: @@ -1467,6 +1555,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей Pal24 payments") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===") + discount_created = await create_discount_offers_table() + if discount_created: + logger.info("✅ Таблица discount_offers готова") + else: + logger.warning("⚠️ Проблемы с таблицей discount_offers") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===") user_messages_created = await create_user_messages_table() if user_messages_created: diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index be876876..29d097d2 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from aiogram import Router, F from aiogram.types import Message, CallbackQuery from aiogram.filters import Command +from aiogram.fsm.context import FSMContext from app.config import settings from app.database.database import get_db @@ -12,11 +13,77 @@ from app.utils.decorators import admin_required from app.utils.pagination import paginate_list from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard from app.localization.texts import get_texts +from app.services.notification_settings_service import NotificationSettingsService +from app.states import AdminStates logger = logging.getLogger(__name__) router = Router() +def _format_toggle(enabled: bool) -> str: + return "🟢 Вкл" if enabled else "🔴 Выкл" + + +def _build_notification_settings_view(language: str): + texts = get_texts(language) + config = NotificationSettingsService.get_config() + + second_percent = NotificationSettingsService.get_second_wave_discount_percent() + second_hours = NotificationSettingsService.get_second_wave_valid_hours() + third_percent = NotificationSettingsService.get_third_wave_discount_percent() + third_hours = NotificationSettingsService.get_third_wave_valid_hours() + third_days = NotificationSettingsService.get_third_wave_trigger_days() + + trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True)) + trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True)) + expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True)) + second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True)) + third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True)) + + summary_text = ( + "🔔 Уведомления пользователям\n\n" + f"• 1 час после триала: {trial_1h_status}\n" + f"• 24 часа после триала: {trial_24h_status}\n" + f"• 1 день после истечения: {expired_1d_status}\n" + f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n" + f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}" + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")], + [InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")], + [InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")], + [InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")], + [InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")], + [InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")], + [InlineKeyboardButton(text=f"{third_wave_status} • {third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")], + [InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")], + [InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")], + [InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")], + ]) + + return summary_text, keyboard + + +async def _render_notification_settings(callback: CallbackQuery) -> None: + language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE) + text, keyboard = _build_notification_settings_view(language) + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + + +async def _render_notification_settings_for_state(bot, chat_id: int, message_id: int, language: str) -> None: + text, keyboard = _build_notification_settings_view(language) + await bot.edit_message_text( + text, + chat_id, + message_id, + parse_mode="HTML", + reply_markup=keyboard, + ) + @router.callback_query(F.data == "admin_monitoring") @admin_required async def admin_monitoring_menu(callback: CallbackQuery): @@ -52,6 +119,180 @@ async def admin_monitoring_menu(callback: CallbackQuery): await callback.answer("❌ Ошибка получения данных", show_alert=True) +@router.callback_query(F.data == "admin_mon_settings") +@admin_required +async def admin_monitoring_settings(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + global_status = "🟢 Включены" if NotificationSettingsService.are_notifications_globally_enabled() else "🔴 Отключены" + second_percent = NotificationSettingsService.get_second_wave_discount_percent() + third_percent = NotificationSettingsService.get_third_wave_discount_percent() + third_days = NotificationSettingsService.get_third_wave_trigger_days() + + text = ( + "⚙️ Настройки мониторинга\n\n" + f"🔔 Уведомления пользователям: {global_status}\n" + f"• Скидка 2-3 дня: {second_percent}%\n" + f"• Скидка после {third_days} дней: {third_percent}%\n\n" + "Выберите раздел для настройки." + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔔 Уведомления пользователям", callback_data="admin_mon_notify_settings")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")], + ]) + + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + + except Exception as e: + logger.error(f"Ошибка отображения настроек мониторинга: {e}") + await callback.answer("❌ Не удалось открыть настройки", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_settings") +@admin_required +async def admin_notify_settings(callback: CallbackQuery): + try: + await _render_notification_settings(callback) + except Exception as e: + logger.error(f"Ошибка отображения настроек уведомлений: {e}") + await callback.answer("❌ Не удалось загрузить настройки", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_trial_1h") +@admin_required +async def toggle_trial_1h_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_trial_inactive_1h_enabled() + NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h") +@admin_required +async def toggle_trial_24h_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_trial_inactive_24h_enabled() + NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d") +@admin_required +async def toggle_expired_1d_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_expired_1d_enabled() + NotificationSettingsService.set_expired_1d_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d") +@admin_required +async def toggle_second_wave_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_second_wave_enabled() + NotificationSettingsService.set_second_wave_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd") +@admin_required +async def toggle_third_wave_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_third_wave_enabled() + NotificationSettingsService.set_third_wave_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +async def _start_notification_value_edit( + callback: CallbackQuery, + state: FSMContext, + setting_key: str, + field: str, + prompt_key: str, + default_prompt: str, +): + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await state.set_state(AdminStates.editing_notification_value) + await state.update_data( + notification_setting_key=setting_key, + notification_setting_field=field, + settings_message_chat=callback.message.chat.id, + settings_message_id=callback.message.message_id, + settings_language=language, + ) + texts = get_texts(language) + await callback.answer() + await callback.message.answer(texts.get(prompt_key, default_prompt)) + + +@router.callback_query(F.data == "admin_mon_notify_edit_2d_percent") +@admin_required +async def edit_second_wave_percent(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_second_wave", + "percent", + "NOTIFY_PROMPT_SECOND_PERCENT", + "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_2d_hours") +@admin_required +async def edit_second_wave_hours(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_second_wave", + "hours", + "NOTIFY_PROMPT_SECOND_HOURS", + "Введите количество часов действия скидки (1-168):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_nd_percent") +@admin_required +async def edit_third_wave_percent(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_third_wave", + "percent", + "NOTIFY_PROMPT_THIRD_PERCENT", + "Введите новый процент скидки для позднего предложения (0-100):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_nd_hours") +@admin_required +async def edit_third_wave_hours(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_third_wave", + "hours", + "NOTIFY_PROMPT_THIRD_HOURS", + "Введите количество часов действия скидки (1-168):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_nd_threshold") +@admin_required +async def edit_third_wave_threshold(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_third_wave", + "trigger", + "NOTIFY_PROMPT_THIRD_DAYS", + "Через сколько дней после истечения отправлять предложение? (минимум 2):", + ) + + @router.callback_query(F.data == "admin_mon_start") @admin_required async def start_monitoring_callback(callback: CallbackQuery): @@ -366,5 +607,53 @@ async def monitoring_command(message: Message): await message.answer(f"❌ Ошибка: {str(e)}") +@router.message(AdminStates.editing_notification_value) +async def process_notification_value_input(message: Message, state: FSMContext): + data = await state.get_data() + if not data: + await state.clear() + await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.") + return + + raw_value = (message.text or "").strip() + try: + value = int(raw_value) + except (TypeError, ValueError): + language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE + texts = get_texts(language) + await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Введите целое число.")) + return + + key = data.get("notification_setting_key") + field = data.get("notification_setting_field") + language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE + texts = get_texts(language) + + success = False + if key == "expired_second_wave" and field == "percent": + success = NotificationSettingsService.set_second_wave_discount_percent(value) + elif key == "expired_second_wave" and field == "hours": + success = NotificationSettingsService.set_second_wave_valid_hours(value) + elif key == "expired_third_wave" and field == "percent": + success = NotificationSettingsService.set_third_wave_discount_percent(value) + elif key == "expired_third_wave" and field == "hours": + success = NotificationSettingsService.set_third_wave_valid_hours(value) + elif key == "expired_third_wave" and field == "trigger": + success = NotificationSettingsService.set_third_wave_trigger_days(value) + + if not success: + await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова.")) + return + + await message.answer(texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены.")) + + chat_id = data.get("settings_message_chat") + message_id = data.get("settings_message_id") + if chat_id and message_id: + await _render_notification_settings_for_state(message.bot, chat_id, message_id, language) + + await state.clear() + + def register_handlers(dp): dp.include_router(router) \ No newline at end of file diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 3f0c182a..3eeee497 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -17,12 +17,13 @@ from app.database.crud.subscription import ( add_subscription_squad, update_subscription_autopay, add_subscription_servers ) -from app.database.crud.user import subtract_user_balance +from app.database.crud.user import subtract_user_balance, add_user_balance from app.database.crud.transaction import create_transaction, get_user_transactions from app.database.models import ( - User, TransactionType, SubscriptionStatus, - SubscriptionServer, Subscription + User, TransactionType, SubscriptionStatus, + SubscriptionServer, Subscription ) +from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed from app.keyboards.inline import ( get_subscription_keyboard, get_trial_keyboard, get_subscription_period_keyboard, get_traffic_packages_keyboard, @@ -4068,6 +4069,76 @@ async def handle_connect_subscription( await callback.answer() +async def claim_discount_offer( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + try: + offer_id = int(callback.data.split("_")[-1]) + except (ValueError, AttributeError): + await callback.answer( + texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), + show_alert=True, + ) + return + + offer = await get_offer_by_id(db, offer_id) + if not offer or offer.user_id != db_user.id: + await callback.answer( + texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), + show_alert=True, + ) + return + + now = datetime.utcnow() + if offer.claimed_at is not None: + await callback.answer( + texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"), + show_alert=True, + ) + return + + if not offer.is_active or offer.expires_at <= now: + offer.is_active = False + await db.commit() + await callback.answer( + texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"), + show_alert=True, + ) + return + + bonus_amount = offer.bonus_amount_kopeks or 0 + if bonus_amount > 0: + success = await add_user_balance( + db, + db_user, + bonus_amount, + texts.get("DISCOUNT_BONUS_DESCRIPTION", "Скидка за продление подписки"), + ) + if not success: + await callback.answer( + texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось начислить скидку. Попробуйте позже."), + show_alert=True, + ) + return + + await mark_offer_claimed(db, offer) + + success_message = texts.get( + "DISCOUNT_CLAIM_SUCCESS", + "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + ).format( + percent=offer.discount_percent, + amount=settings.format_price(bonus_amount), + ) + + await callback.answer("✅ Скидка активирована!", show_alert=True) + await callback.message.answer(success_message) + + async def handle_device_guide( callback: types.CallbackQuery, db_user: User, @@ -4963,6 +5034,11 @@ def register_handlers(dp: Dispatcher): F.data == "countries_apply" ) + dp.callback_query.register( + claim_discount_offer, + F.data.startswith("claim_discount_") + ) + dp.callback_query.register( handle_connect_subscription, F.data == "subscription_connect" diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 8219147b..13c37204 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -99,6 +99,12 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave"), InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring") ], + [ + InlineKeyboardButton( + text=texts.t("ADMIN_MONITORING_SETTINGS", "⚙️ Настройки мониторинга"), + callback_data="admin_mon_settings" + ) + ], [ InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"), InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel") diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index a190aec4..337e18f8 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -21,10 +21,15 @@ from app.database.crud.notification import ( notification_sent, record_notification, ) +from app.database.crud.discount_offer import ( + upsert_discount_offer, + deactivate_expired_offers, +) from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus from app.services.subscription_service import SubscriptionService from app.services.payment_service import PaymentService from app.localization.texts import get_texts +from app.services.notification_settings_service import NotificationSettingsService from app.external.remnawave_api import ( RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError @@ -80,10 +85,16 @@ class MonitoringService: async for db in get_db(): try: await self._cleanup_notification_cache() - + + expired_offers = await deactivate_expired_offers(db) + if expired_offers: + logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений") + await self._check_expired_subscriptions(db) await self._check_expiring_subscriptions(db) - await self._check_trial_expiring_soon(db) + await self._check_trial_expiring_soon(db) + await self._check_trial_inactivity_notifications(db) + await self._check_expired_subscription_followups(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) await self._sync_with_remnawave(db) @@ -250,7 +261,7 @@ class MonitoringService: async def _check_trial_expiring_soon(self, db: AsyncSession): try: threshold_time = datetime.utcnow() + timedelta(hours=2) - + result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -288,7 +299,202 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}") - + + async def _check_trial_inactivity_notifications(self, db: AsyncSession): + if not NotificationSettingsService.are_notifications_globally_enabled(): + return + if not self.bot: + return + + try: + now = datetime.utcnow() + one_hour_ago = now - timedelta(hours=1) + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.is_trial == True, + Subscription.start_date.isnot(None), + Subscription.start_date <= one_hour_ago, + Subscription.end_date > now, + ) + ) + ) + + subscriptions = result.scalars().all() + sent_1h = 0 + sent_24h = 0 + + for subscription in subscriptions: + user = subscription.user + if not user: + continue + + if (subscription.traffic_used_gb or 0) > 0: + continue + + start_date = subscription.start_date + if not start_date: + continue + + time_since_start = now - start_date + + if (NotificationSettingsService.is_trial_inactive_1h_enabled() + and timedelta(hours=1) <= time_since_start < timedelta(hours=24)): + if not await notification_sent(db, user.id, subscription.id, "trial_inactive_1h"): + success = await self._send_trial_inactive_notification(user, subscription, 1) + if success: + await record_notification(db, user.id, subscription.id, "trial_inactive_1h") + sent_1h += 1 + + if NotificationSettingsService.is_trial_inactive_24h_enabled() and time_since_start >= timedelta(hours=24): + if not await notification_sent(db, user.id, subscription.id, "trial_inactive_24h"): + success = await self._send_trial_inactive_notification(user, subscription, 24) + if success: + await record_notification(db, user.id, subscription.id, "trial_inactive_24h") + sent_24h += 1 + + if sent_1h or sent_24h: + await self._log_monitoring_event( + db, + "trial_inactivity_notifications", + f"Отправлено {sent_1h} уведомлений спустя 1 час и {sent_24h} спустя 24 часа", + {"sent_1h": sent_1h, "sent_24h": sent_24h}, + ) + + except Exception as e: + logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}") + + async def _check_expired_subscription_followups(self, db: AsyncSession): + if not NotificationSettingsService.are_notifications_globally_enabled(): + return + if not self.bot: + return + + try: + now = datetime.utcnow() + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.is_trial == False, + Subscription.end_date <= now, + ) + ) + ) + + subscriptions = result.scalars().all() + sent_day1 = 0 + sent_wave2 = 0 + sent_wave3 = 0 + + for subscription in subscriptions: + user = subscription.user + if not user: + continue + + if subscription.end_date is None: + continue + + time_since_end = now - subscription.end_date + if time_since_end.total_seconds() < 0: + continue + + days_since = time_since_end.total_seconds() / 86400 + + # Day 1 reminder + if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2: + if not await notification_sent(db, user.id, subscription.id, "expired_1d"): + success = await self._send_expired_day1_notification(user, subscription) + if success: + await record_notification(db, user.id, subscription.id, "expired_1d") + sent_day1 += 1 + + # Second wave (2-3 days) discount + if NotificationSettingsService.is_second_wave_enabled() and 2 <= days_since < 4: + if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"): + percent = NotificationSettingsService.get_second_wave_discount_percent() + valid_hours = NotificationSettingsService.get_second_wave_valid_hours() + bonus_amount = settings.PRICE_30_DAYS * percent // 100 + offer = await upsert_discount_offer( + db, + user_id=user.id, + subscription_id=subscription.id, + notification_type="expired_discount_wave2", + discount_percent=percent, + bonus_amount_kopeks=bonus_amount, + valid_hours=valid_hours, + ) + success = await self._send_expired_discount_notification( + user, + subscription, + percent, + offer.expires_at, + offer.id, + "second", + bonus_amount, + ) + if success: + await record_notification(db, user.id, subscription.id, "expired_discount_wave2") + sent_wave2 += 1 + + # Third wave (N days) discount + if NotificationSettingsService.is_third_wave_enabled(): + trigger_days = NotificationSettingsService.get_third_wave_trigger_days() + if trigger_days <= days_since < trigger_days + 1: + if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"): + percent = NotificationSettingsService.get_third_wave_discount_percent() + valid_hours = NotificationSettingsService.get_third_wave_valid_hours() + bonus_amount = settings.PRICE_30_DAYS * percent // 100 + offer = await upsert_discount_offer( + db, + user_id=user.id, + subscription_id=subscription.id, + notification_type="expired_discount_wave3", + discount_percent=percent, + bonus_amount_kopeks=bonus_amount, + valid_hours=valid_hours, + ) + success = await self._send_expired_discount_notification( + user, + subscription, + percent, + offer.expires_at, + offer.id, + "third", + bonus_amount, + trigger_days=trigger_days, + ) + if success: + await record_notification(db, user.id, subscription.id, "expired_discount_wave3") + sent_wave3 += 1 + + if sent_day1 or sent_wave2 or sent_wave3: + await self._log_monitoring_event( + db, + "expired_followups_sent", + ( + "Follow-ups: 1д={0}, скидка 2-3д={1}, скидка N={2}".format( + sent_day1, + sent_wave2, + sent_wave3, + ) + ), + { + "day1": sent_day1, + "wave2": sent_wave2, + "wave3": sent_wave3, + }, + ) + + except Exception as e: + logger.error(f"Ошибка проверки напоминаний об истекшей подписке: {e}") + async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]: current_time = datetime.utcnow() threshold_date = current_time + timedelta(days=days_before) @@ -465,7 +671,7 @@ class MonitoringService: async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool: try: texts = get_texts(user.language) - + message = f""" 🎁 Тестовая подписка скоро закончится! @@ -501,7 +707,149 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}") return False - + + async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, hours: int) -> bool: + try: + texts = get_texts(user.language) + if hours >= 24: + template = texts.get( + "TRIAL_INACTIVE_24H", + ( + "⏳ Вы ещё не подключились к VPN\n\n" + "Прошли сутки с активации тестового периода, но трафик не зафиксирован." + "\n\nНажмите кнопку ниже, чтобы подключиться." + ), + ) + else: + template = texts.get( + "TRIAL_INACTIVE_1H", + ( + "⏳ Прошёл час, а подключения нет\n\n" + "Если возникли сложности с запуском — воспользуйтесь инструкциями." + ), + ) + + message = template.format( + price=settings.format_price(settings.PRICE_30_DAYS), + end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], + [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], + ]) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error(f"Ошибка отправки уведомления об отсутствии подключения пользователю {user.telegram_id}: {e}") + return False + + async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool: + try: + texts = get_texts(user.language) + template = texts.get( + "SUBSCRIPTION_EXPIRED_1D", + ( + "⛔ Подписка закончилась\n\n" + "Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис." + ), + ) + message = template.format( + end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + price=settings.format_price(settings.PRICE_30_DAYS), + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")], + [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")], + [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], + ]) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error(f"Ошибка отправки напоминания об истекшей подписке пользователю {user.telegram_id}: {e}") + return False + + async def _send_expired_discount_notification( + self, + user: User, + subscription: Subscription, + percent: int, + expires_at: datetime, + offer_id: int, + wave: str, + bonus_amount: int, + trigger_days: int = None, + ) -> bool: + try: + texts = get_texts(user.language) + + if wave == "second": + template = texts.get( + "SUBSCRIPTION_EXPIRED_SECOND_WAVE", + ( + "🔥 Скидка {percent}% на продление\n\n" + "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. " + "Предложение действует до {expires_at}." + ), + ) + else: + template = texts.get( + "SUBSCRIPTION_EXPIRED_THIRD_WAVE", + ( + "🎁 Индивидуальная скидка {percent}%\n\n" + "Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. " + "Скидка действует до {expires_at}." + ), + ) + + message = template.format( + percent=percent, + bonus=settings.format_price(bonus_amount), + expires_at=expires_at.strftime("%d.%m.%Y %H:%M"), + trigger_days=trigger_days or "", + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎁 Получить скидку", callback_data=f"claim_discount_{offer_id}")], + [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")], + [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")], + [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], + ]) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error(f"Ошибка отправки скидочного уведомления пользователю {user.telegram_id}: {e}") + return False + async def _send_autopay_success_notification(self, user: User, amount: int, days: int): try: texts = get_texts(user.language) diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py new file mode 100644 index 00000000..a19edffd --- /dev/null +++ b/app/services/notification_settings_service.py @@ -0,0 +1,249 @@ +import json +import json +import logging +from copy import deepcopy +from pathlib import Path +from typing import Any, Dict + +from app.config import settings + + +logger = logging.getLogger(__name__) + + +class NotificationSettingsService: + """Runtime-editable notification settings stored on disk.""" + + _storage_path: Path = Path("data/notification_settings.json") + _data: Dict[str, Dict[str, Any]] = {} + _loaded: bool = False + + _DEFAULTS: Dict[str, Dict[str, Any]] = { + "trial_inactive_1h": {"enabled": True}, + "trial_inactive_24h": {"enabled": True}, + "expired_1d": {"enabled": True}, + "expired_second_wave": { + "enabled": True, + "discount_percent": 10, + "valid_hours": 24, + }, + "expired_third_wave": { + "enabled": True, + "discount_percent": 20, + "valid_hours": 24, + "trigger_days": 5, + }, + } + + @classmethod + def _ensure_dir(cls) -> None: + try: + cls._storage_path.parent.mkdir(parents=True, exist_ok=True) + except Exception as exc: # pragma: no cover - filesystem guard + logger.error("Failed to create notification settings dir: %s", exc) + + @classmethod + def _load(cls) -> None: + if cls._loaded: + return + + cls._ensure_dir() + try: + if cls._storage_path.exists(): + raw = cls._storage_path.read_text(encoding="utf-8") + cls._data = json.loads(raw) if raw.strip() else {} + else: + cls._data = {} + except Exception as exc: + logger.error("Failed to load notification settings: %s", exc) + cls._data = {} + + changed = cls._apply_defaults() + if changed: + cls._save() + cls._loaded = True + + @classmethod + def _apply_defaults(cls) -> bool: + changed = False + for key, defaults in cls._DEFAULTS.items(): + current = cls._data.get(key) + if not isinstance(current, dict): + cls._data[key] = deepcopy(defaults) + changed = True + continue + + for def_key, def_value in defaults.items(): + if def_key not in current: + current[def_key] = def_value + changed = True + return changed + + @classmethod + def _save(cls) -> bool: + cls._ensure_dir() + try: + cls._storage_path.write_text( + json.dumps(cls._data, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return True + except Exception as exc: + logger.error("Failed to save notification settings: %s", exc) + return False + + @classmethod + def _get(cls, key: str) -> Dict[str, Any]: + cls._load() + value = cls._data.get(key) + if not isinstance(value, dict): + value = deepcopy(cls._DEFAULTS.get(key, {})) + cls._data[key] = value + return value + + @classmethod + def get_config(cls) -> Dict[str, Dict[str, Any]]: + cls._load() + return deepcopy(cls._data) + + @classmethod + def _set_field(cls, key: str, field: str, value: Any) -> bool: + cls._load() + section = cls._get(key) + section[field] = value + cls._data[key] = section + return cls._save() + + @classmethod + def set_enabled(cls, key: str, enabled: bool) -> bool: + return cls._set_field(key, "enabled", bool(enabled)) + + @classmethod + def is_enabled(cls, key: str) -> bool: + return bool(cls._get(key).get("enabled", True)) + + # Trial inactivity helpers + @classmethod + def is_trial_inactive_1h_enabled(cls) -> bool: + return cls.is_enabled("trial_inactive_1h") + + @classmethod + def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("trial_inactive_1h", enabled) + + @classmethod + def is_trial_inactive_24h_enabled(cls) -> bool: + return cls.is_enabled("trial_inactive_24h") + + @classmethod + def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("trial_inactive_24h", enabled) + + # Expired subscription notifications + @classmethod + def is_expired_1d_enabled(cls) -> bool: + return cls.is_enabled("expired_1d") + + @classmethod + def set_expired_1d_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("expired_1d", enabled) + + @classmethod + def is_second_wave_enabled(cls) -> bool: + return cls.is_enabled("expired_second_wave") + + @classmethod + def set_second_wave_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("expired_second_wave", enabled) + + @classmethod + def get_second_wave_discount_percent(cls) -> int: + value = cls._get("expired_second_wave").get("discount_percent", 10) + try: + return max(0, min(100, int(value))) + except (TypeError, ValueError): + return 10 + + @classmethod + def set_second_wave_discount_percent(cls, percent: int) -> bool: + try: + percent_int = max(0, min(100, int(percent))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_second_wave", "discount_percent", percent_int) + + @classmethod + def get_second_wave_valid_hours(cls) -> int: + value = cls._get("expired_second_wave").get("valid_hours", 24) + try: + return max(1, min(168, int(value))) + except (TypeError, ValueError): + return 24 + + @classmethod + def set_second_wave_valid_hours(cls, hours: int) -> bool: + try: + hours_int = max(1, min(168, int(hours))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_second_wave", "valid_hours", hours_int) + + @classmethod + def is_third_wave_enabled(cls) -> bool: + return cls.is_enabled("expired_third_wave") + + @classmethod + def set_third_wave_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("expired_third_wave", enabled) + + @classmethod + def get_third_wave_discount_percent(cls) -> int: + value = cls._get("expired_third_wave").get("discount_percent", 20) + try: + return max(0, min(100, int(value))) + except (TypeError, ValueError): + return 20 + + @classmethod + def set_third_wave_discount_percent(cls, percent: int) -> bool: + try: + percent_int = max(0, min(100, int(percent))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_third_wave", "discount_percent", percent_int) + + @classmethod + def get_third_wave_valid_hours(cls) -> int: + value = cls._get("expired_third_wave").get("valid_hours", 24) + try: + return max(1, min(168, int(value))) + except (TypeError, ValueError): + return 24 + + @classmethod + def set_third_wave_valid_hours(cls, hours: int) -> bool: + try: + hours_int = max(1, min(168, int(hours))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_third_wave", "valid_hours", hours_int) + + @classmethod + def get_third_wave_trigger_days(cls) -> int: + value = cls._get("expired_third_wave").get("trigger_days", 5) + try: + return max(2, min(60, int(value))) + except (TypeError, ValueError): + return 5 + + @classmethod + def set_third_wave_trigger_days(cls, days: int) -> bool: + try: + days_int = max(2, min(60, int(days))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_third_wave", "trigger_days", days_int) + + @classmethod + def are_notifications_globally_enabled(cls) -> bool: + return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True)) diff --git a/app/states.py b/app/states.py index 45e87e21..782fae7d 100644 --- a/app/states.py +++ b/app/states.py @@ -84,9 +84,10 @@ class AdminStates(StatesGroup): editing_device_price = State() editing_user_devices = State() editing_user_traffic = State() - + editing_rules_page = State() - + editing_notification_value = State() + confirming_sync = State() editing_server_name = State() diff --git a/locales/en.json b/locales/en.json index 1b416564..2f370239 100644 --- a/locales/en.json +++ b/locales/en.json @@ -129,6 +129,7 @@ "ACCESS_DENIED": "❌ Access denied", "ADMIN_MESSAGES": "📨 Broadcasts", "ADMIN_MONITORING": "🔍 Monitoring", + "ADMIN_MONITORING_SETTINGS": "⚙️ Monitoring settings", "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", "ADMIN_PROMOCODES": "🎫 Promo codes", "ADMIN_REFERRALS": "🤝 Referral program", @@ -484,5 +485,23 @@ "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance." + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", + "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!", + "TRIAL_INACTIVE_24H": "⏳ A full day passed without activity\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Your subscription expired\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.", + "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.", + "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", + "NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.", + "NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.", + "NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):", + "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):", + "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):", + "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):" } diff --git a/locales/ru.json b/locales/ru.json index 736d38e1..5a9218f1 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -5,6 +5,7 @@ "ADMIN_CAMPAIGNS": "📣 Рекламные кампании", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MONITORING": "🔍 Мониторинг", + "ADMIN_MONITORING_SETTINGS": "⚙️ Настройки мониторинга", "ADMIN_REPORTS": "📊 Отчеты", "ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n", "ADMIN_PROMOCODES": "🎫 Промокоды", @@ -484,5 +485,23 @@ "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку." + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", + "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!", + "TRIAL_INACTIVE_24H": "⏳ Прошли сутки с начала теста\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Подписка закончилась\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", + "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", + "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", + "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", + "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", + "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", + "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):", + "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):" } From e414ae40d42ffb0c763b76872bf2b1efa526910f Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:40:04 +0300 Subject: [PATCH 039/146] Handle unreachable users in monitoring notifications --- app/services/monitoring_service.py | 205 ++++++++++++++++++++++++----- locales/en.json | 3 + locales/ru.json | 3 + 3 files changed, 180 insertions(+), 31 deletions(-) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 337e18f8..9225be24 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -2,37 +2,46 @@ import asyncio import logging from datetime import datetime, timedelta from typing import Dict, List, Any, Optional, Set -from sqlalchemy.ext.asyncio import AsyncSession + +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from sqlalchemy import select, and_, or_ +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.config import settings from app.database.database import get_db -from app.database.crud.subscription import ( - get_expired_subscriptions, get_expiring_subscriptions, - get_subscriptions_for_autopay, deactivate_subscription, - extend_subscription -) -from app.database.crud.user import ( - get_user_by_id, get_inactive_users, delete_user, - subtract_user_balance +from app.database.crud.discount_offer import ( + deactivate_expired_offers, + upsert_discount_offer, ) from app.database.crud.notification import ( notification_sent, record_notification, ) -from app.database.crud.discount_offer import ( - upsert_discount_offer, - deactivate_expired_offers, +from app.database.crud.subscription import ( + deactivate_subscription, + extend_subscription, + get_expired_subscriptions, + get_expiring_subscriptions, + get_subscriptions_for_autopay, +) +from app.database.crud.user import ( + delete_user, + get_inactive_users, + get_user_by_id, + subtract_user_balance, ) from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus -from app.services.subscription_service import SubscriptionService -from app.services.payment_service import PaymentService from app.localization.texts import get_texts from app.services.notification_settings_service import NotificationSettingsService +from app.services.payment_service import PaymentService +from app.services.subscription_service import SubscriptionService from app.external.remnawave_api import ( - RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError + RemnaWaveAPIError, + RemnaWaveUser, + TrafficLimitStrategy, + UserStatus, ) logger = logging.getLogger(__name__) @@ -45,9 +54,43 @@ class MonitoringService: self.subscription_service = SubscriptionService() self.payment_service = PaymentService() self.bot = bot - self._notified_users: Set[str] = set() + self._notified_users: Set[str] = set() self._last_cleanup = datetime.utcnow() self._sla_task = None + + @staticmethod + def _is_unreachable_error(error: TelegramBadRequest) -> bool: + message = str(error).lower() + unreachable_markers = ( + "chat not found", + "user is deactivated", + "bot was blocked by the user", + "bot can't initiate conversation", + "can't initiate conversation", + "user not found", + "peer id invalid", + ) + return any(marker in message for marker in unreachable_markers) + + def _handle_unreachable_user(self, user: User, error: Exception, context: str) -> bool: + if isinstance(error, TelegramForbiddenError): + logger.warning( + "⚠️ Пользователь %s недоступен (%s): бот заблокирован", + user.telegram_id, + context, + ) + return True + + if isinstance(error, TelegramBadRequest) and self._is_unreachable_error(error): + logger.warning( + "⚠️ Пользователь %s недоступен (%s): %s", + user.telegram_id, + context, + error, + ) + return True + + return False async def start_monitoring(self): if self.is_running: @@ -619,9 +662,22 @@ class MonitoringService: reply_markup=keyboard ) return True - + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "уведомление об истечении подписки"): + return True + logger.error( + "Ошибка Telegram API при отправке уведомления об истечении подписки пользователю %s: %s", + user.telegram_id, + exc, + ) + return False except Exception as e: - logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки уведомления об истечении подписки пользователю %s: %s", + user.telegram_id, + e, + ) return False async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool: @@ -663,9 +719,22 @@ class MonitoringService: reply_markup=keyboard ) return True - + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "уведомление об истекающей подписке"): + return True + logger.error( + "Ошибка Telegram API при отправке уведомления об истечении подписки пользователю %s: %s", + user.telegram_id, + exc, + ) + return False except Exception as e: - logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки уведомления об истечении подписки пользователю %s: %s", + user.telegram_id, + e, + ) return False async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool: @@ -703,9 +772,22 @@ class MonitoringService: reply_markup=keyboard ) return True - + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "уведомление о завершении тестовой подписки"): + return True + logger.error( + "Ошибка Telegram API при отправке уведомления о завершении тестовой подписки пользователю %s: %s", + user.telegram_id, + exc, + ) + return False except Exception as e: - logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки уведомления об окончании тестовой подписки пользователю %s: %s", + user.telegram_id, + e, + ) return False async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, hours: int) -> bool: @@ -750,8 +832,21 @@ class MonitoringService: ) return True + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "уведомление о бездействии на тесте"): + return True + logger.error( + "Ошибка Telegram API при отправке уведомления об отсутствии подключения пользователю %s: %s", + user.telegram_id, + exc, + ) + return False except Exception as e: - logger.error(f"Ошибка отправки уведомления об отсутствии подключения пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки уведомления об отсутствии подключения пользователю %s: %s", + user.telegram_id, + e, + ) return False async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool: @@ -785,8 +880,21 @@ class MonitoringService: ) return True + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "напоминание об истекшей подписке"): + return True + logger.error( + "Ошибка Telegram API при отправке напоминания об истекшей подписке пользователю %s: %s", + user.telegram_id, + exc, + ) + return False except Exception as e: - logger.error(f"Ошибка отправки напоминания об истекшей подписке пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки напоминания об истекшей подписке пользователю %s: %s", + user.telegram_id, + e, + ) return False async def _send_expired_discount_notification( @@ -846,8 +954,21 @@ class MonitoringService: ) return True + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "скидочное уведомление"): + return True + logger.error( + "Ошибка Telegram API при отправке скидочного уведомления пользователю %s: %s", + user.telegram_id, + exc, + ) + return False except Exception as e: - logger.error(f"Ошибка отправки скидочного уведомления пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки скидочного уведомления пользователю %s: %s", + user.telegram_id, + e, + ) return False async def _send_autopay_success_notification(self, user: User, amount: int, days: int): @@ -858,9 +979,20 @@ class MonitoringService: amount=settings.format_price(amount) ) await self.bot.send_message(user.telegram_id, message, parse_mode="HTML") + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if not self._handle_unreachable_user(user, exc, "уведомление об успешном автоплатеже"): + logger.error( + "Ошибка Telegram API при отправке уведомления об автоплатеже пользователю %s: %s", + user.telegram_id, + exc, + ) except Exception as e: - logger.error(f"Ошибка отправки уведомления об автоплатеже пользователю {user.telegram_id}: {e}") - + logger.error( + "Ошибка отправки уведомления об автоплатеже пользователю %s: %s", + user.telegram_id, + e, + ) + async def _send_autopay_failed_notification(self, user: User, balance: int, required: int): try: texts = get_texts(user.language) @@ -877,14 +1009,25 @@ class MonitoringService: ]) await self.bot.send_message( - user.telegram_id, - message, + user.telegram_id, + message, parse_mode="HTML", reply_markup=keyboard ) - + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if not self._handle_unreachable_user(user, exc, "уведомление о неудачном автоплатеже"): + logger.error( + "Ошибка Telegram API при отправке уведомления о неудачном автоплатеже пользователю %s: %s", + user.telegram_id, + exc, + ) except Exception as e: - logger.error(f"Ошибка отправки уведомления о неудачном автоплатеже пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки уведомления о неудачном автоплатеже пользователю %s: %s", + user.telegram_id, + e, + ) async def _cleanup_inactive_users(self, db: AsyncSession): try: diff --git a/locales/en.json b/locales/en.json index 2f370239..bb1b7fc8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -107,6 +107,7 @@ "SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!", "SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!", "SUBSCRIPTION_ACTIVE": "✅ Active", + "SUBSCRIPTION_EXTEND": "💎 Extend subscription", "SUCCESS": "✅ Success", "REGISTRATION_COMPLETING": "✅ Completing registration...", "SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic", @@ -147,6 +148,7 @@ "CREATE_TICKET_BUTTON": "🎫 Create ticket", "MY_TICKETS_BUTTON": "📋 My tickets", "CONTACT_SUPPORT_BUTTON": "💬 Contact support", + "SUPPORT_BUTTON": "🆘 Support", "TICKET_PRIORITY_SELECT": "Select ticket priority:", "TICKET_PRIORITY_LOW": "🟢 Low", "TICKET_PRIORITY_NORMAL": "🟡 Normal", @@ -272,6 +274,7 @@ "BALANCE_INFO": "\n💰 Balance: {balance}\n\nChoose an action:\n", "BALANCE_SUPPORT_REQUEST": "🛠️ Request via support", "BALANCE_TOP_UP": "💳 Top up", + "BALANCE_TOPUP": "💳 Top up balance", "CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.", "CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!", "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!", diff --git a/locales/ru.json b/locales/ru.json index 5a9218f1..ff9fa404 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -24,6 +24,7 @@ "CREATE_TICKET_BUTTON": "🎫 Создать тикет", "MY_TICKETS_BUTTON": "📋 Мои тикеты", "CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой", + "SUPPORT_BUTTON": "🆘 Поддержка", "TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:", "TICKET_PRIORITY_LOW": "🟢 Низкий", "TICKET_PRIORITY_NORMAL": "🟡 Обычный", @@ -154,6 +155,7 @@ "BALANCE_INFO": "\n💰 Баланс: {balance}\n\nВыберите действие:\n", "BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку", "BALANCE_TOP_UP": "💳 Пополнить", + "BALANCE_TOPUP": "💳 Пополнить баланс", "CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.", "CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!", "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!", @@ -298,6 +300,7 @@ "SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки", "SKIP_BUTTON": "⏭️ Пропустить", "SUBSCRIPTION_ACTIVE": "✅ Активна", + "SUBSCRIPTION_EXTEND": "💎 Продлить подписку", "SUBSCRIPTION_EXPIRED": "\n❌ Подписка истекла\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n", "SUBSCRIPTION_EXPIRING": "\n⚠️ Подписка истекает!\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n", "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Подписка истекает через {days_text}!\n\nВаша платная подписка истекает {end_date}.\n\n💳 Автоплатеж: {autopay_status}\n\n{action_text}\n", From 0edfa973e4bad7ad13b7ca644d4b41c724845d14 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:47:33 +0300 Subject: [PATCH 040/146] Limit unknown message handler to default state --- app/handlers/common.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/handlers/common.py b/app/handlers/common.py index fab936a4..3c8549f2 100644 --- a/app/handlers/common.py +++ b/app/handlers/common.py @@ -1,5 +1,6 @@ import logging from aiogram import Dispatcher, types, F +from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -124,6 +125,7 @@ def register_handlers(dp: Dispatcher): # чтобы их обработка не прерывалась общим хендлером неизвестных сообщений dp.message.register( handle_unknown_message, + StateFilter(None), F.successful_payment.is_(None) ) \ No newline at end of file From 4cb23dc2b1e8c3478a65915933ab5cb234a3e5b3 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:49:36 +0300 Subject: [PATCH 041/146] Revert "Limit unknown message handler to default state" --- app/handlers/common.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/handlers/common.py b/app/handlers/common.py index 3c8549f2..fab936a4 100644 --- a/app/handlers/common.py +++ b/app/handlers/common.py @@ -1,6 +1,5 @@ import logging from aiogram import Dispatcher, types, F -from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -125,7 +124,6 @@ def register_handlers(dp: Dispatcher): # чтобы их обработка не прерывалась общим хендлером неизвестных сообщений dp.message.register( handle_unknown_message, - StateFilter(None), F.successful_payment.is_(None) ) \ No newline at end of file From 214488cf80c79d3983b616093d120ec8f88efed6 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:49:58 +0300 Subject: [PATCH 042/146] Fix admin notification editing state filter --- app/handlers/admin/monitoring.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 29d097d2..b6419c62 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -3,7 +3,7 @@ import logging from datetime import datetime, timedelta from aiogram import Router, F from aiogram.types import Message, CallbackQuery -from aiogram.filters import Command +from aiogram.filters import Command, StateFilter from aiogram.fsm.context import FSMContext from app.config import settings @@ -607,7 +607,7 @@ async def monitoring_command(message: Message): await message.answer(f"❌ Ошибка: {str(e)}") -@router.message(AdminStates.editing_notification_value) +@router.message(StateFilter(AdminStates.editing_notification_value)) async def process_notification_value_input(message: Message, state: FSMContext): data = await state.get_data() if not data: From 1f780a0ae81fbc25e40c32cd92820367c59b5199 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:51:44 +0300 Subject: [PATCH 043/146] Revert "Fix admin notification editing state filter" --- app/handlers/admin/monitoring.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index b6419c62..29d097d2 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -3,7 +3,7 @@ import logging from datetime import datetime, timedelta from aiogram import Router, F from aiogram.types import Message, CallbackQuery -from aiogram.filters import Command, StateFilter +from aiogram.filters import Command from aiogram.fsm.context import FSMContext from app.config import settings @@ -607,7 +607,7 @@ async def monitoring_command(message: Message): await message.answer(f"❌ Ошибка: {str(e)}") -@router.message(StateFilter(AdminStates.editing_notification_value)) +@router.message(AdminStates.editing_notification_value) async def process_notification_value_input(message: Message, state: FSMContext): data = await state.get_data() if not data: From c51158bad15221c71b326ec260a30ffbb975d75f Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:52:32 +0300 Subject: [PATCH 044/146] Fix notification edit input blocked by unknown handler --- app/handlers/common.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/handlers/common.py b/app/handlers/common.py index fab936a4..3c8549f2 100644 --- a/app/handlers/common.py +++ b/app/handlers/common.py @@ -1,5 +1,6 @@ import logging from aiogram import Dispatcher, types, F +from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -124,6 +125,7 @@ def register_handlers(dp: Dispatcher): # чтобы их обработка не прерывалась общим хендлером неизвестных сообщений dp.message.register( handle_unknown_message, + StateFilter(None), F.successful_payment.is_(None) ) \ No newline at end of file From 97996048aedce6f61b51c5526f4fe675b599185c Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:58:32 +0300 Subject: [PATCH 045/146] Fix notification edit message call --- app/handlers/admin/monitoring.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 29d097d2..dd6d6a4b 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -77,9 +77,9 @@ async def _render_notification_settings(callback: CallbackQuery) -> None: async def _render_notification_settings_for_state(bot, chat_id: int, message_id: int, language: str) -> None: text, keyboard = _build_notification_settings_view(language) await bot.edit_message_text( - text, - chat_id, - message_id, + text=text, + chat_id=chat_id, + message_id=message_id, parse_mode="HTML", reply_markup=keyboard, ) From 711ad05983361f3760d63f527b7406293deb6e20 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 09:00:47 +0300 Subject: [PATCH 046/146] Revert "Fix notification edit message call" --- app/handlers/admin/monitoring.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index dd6d6a4b..29d097d2 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -77,9 +77,9 @@ async def _render_notification_settings(callback: CallbackQuery) -> None: async def _render_notification_settings_for_state(bot, chat_id: int, message_id: int, language: str) -> None: text, keyboard = _build_notification_settings_view(language) await bot.edit_message_text( - text=text, - chat_id=chat_id, - message_id=message_id, + text, + chat_id, + message_id, parse_mode="HTML", reply_markup=keyboard, ) From 773dc118886c88e7b358591354634230c2bd4cab Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 09:01:15 +0300 Subject: [PATCH 047/146] Fix business connection id handling in notification editor --- app/handlers/admin/monitoring.py | 42 +++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 29d097d2..5ecd212a 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -74,15 +74,27 @@ async def _render_notification_settings(callback: CallbackQuery) -> None: await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) -async def _render_notification_settings_for_state(bot, chat_id: int, message_id: int, language: str) -> None: +async def _render_notification_settings_for_state( + bot, + chat_id: int, + message_id: int, + language: str, + business_connection_id: str | None = None, +) -> None: text, keyboard = _build_notification_settings_view(language) - await bot.edit_message_text( - text, - chat_id, - message_id, - parse_mode="HTML", - reply_markup=keyboard, - ) + + edit_kwargs = { + "text": text, + "chat_id": chat_id, + "message_id": message_id, + "parse_mode": "HTML", + "reply_markup": keyboard, + } + + if business_connection_id: + edit_kwargs["business_connection_id"] = business_connection_id + + await bot.edit_message_text(**edit_kwargs) @router.callback_query(F.data == "admin_monitoring") @admin_required @@ -221,6 +233,11 @@ async def _start_notification_value_edit( notification_setting_field=field, settings_message_chat=callback.message.chat.id, settings_message_id=callback.message.message_id, + settings_business_connection_id=( + str(getattr(callback.message, "business_connection_id", None)) + if getattr(callback.message, "business_connection_id", None) is not None + else None + ), settings_language=language, ) texts = get_texts(language) @@ -649,8 +666,15 @@ async def process_notification_value_input(message: Message, state: FSMContext): chat_id = data.get("settings_message_chat") message_id = data.get("settings_message_id") + business_connection_id = data.get("settings_business_connection_id") if chat_id and message_id: - await _render_notification_settings_for_state(message.bot, chat_id, message_id, language) + await _render_notification_settings_for_state( + message.bot, + chat_id, + message_id, + language, + business_connection_id=business_connection_id, + ) await state.clear() From 14412871bf30af842670235291a4602caeee8fe5 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 09:09:02 +0300 Subject: [PATCH 048/146] Allow admins to bypass channel subscription check --- app/middlewares/channel_checker.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index bf6669b5..38777cd9 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -49,6 +49,16 @@ class ChannelCheckerMiddleware(BaseMiddleware): return await handler(event, data) + # Админам разрешаем пропускать проверку подписки, чтобы не блокировать + # работу панели управления даже при отсутствии подписки. Важно делать + # это до обращения к состоянию, чтобы не выполнять лишние операции. + if settings.is_admin(telegram_id): + logger.debug( + "✅ Пользователь %s является администратором — пропускаем проверку подписки", + telegram_id, + ) + return await handler(event, data) + state: FSMContext = data.get('state') current_state = None From 2cbff1583744915c29fb8caf2781e184beb715a2 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 09:15:47 +0300 Subject: [PATCH 049/146] Handle missing text when updating notification settings --- app/handlers/admin/monitoring.py | 36 ++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 5ecd212a..b740a832 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -5,6 +5,7 @@ from aiogram import Router, F from aiogram.types import Message, CallbackQuery from aiogram.filters import Command from aiogram.fsm.context import FSMContext +from aiogram.exceptions import TelegramBadRequest from app.config import settings from app.database.database import get_db @@ -71,7 +72,17 @@ def _build_notification_settings_view(language: str): async def _render_notification_settings(callback: CallbackQuery) -> None: language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE) text, keyboard = _build_notification_settings_view(language) - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + try: + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + except TelegramBadRequest as e: + if "message to edit" in str(e).lower(): + logger.warning( + "Не удалось изменить текст сообщения с настройками уведомлений, отправляем новое сообщение: %s", + e, + ) + await callback.message.answer(text, parse_mode="HTML", reply_markup=keyboard) + else: + raise async def _render_notification_settings_for_state( @@ -94,7 +105,28 @@ async def _render_notification_settings_for_state( if business_connection_id: edit_kwargs["business_connection_id"] = business_connection_id - await bot.edit_message_text(**edit_kwargs) + try: + await bot.edit_message_text(**edit_kwargs) + except TelegramBadRequest as e: + if "message to edit" in str(e).lower(): + logger.warning( + "Не удалось изменить текст сообщения (chat=%s, message=%s), отправляем новое: %s", + chat_id, + message_id, + e, + ) + send_kwargs = { + "chat_id": chat_id, + "text": text, + "parse_mode": "HTML", + "reply_markup": keyboard, + } + if business_connection_id: + send_kwargs["business_connection_id"] = business_connection_id + + await bot.send_message(**send_kwargs) + else: + raise @router.callback_query(F.data == "admin_monitoring") @admin_required From 39577b31bddff2e15218e739318c7b6b54d4bdac Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 09:17:48 +0300 Subject: [PATCH 050/146] Revert "Handle missing text when updating notification settings" --- app/handlers/admin/monitoring.py | 36 ++------------------------------ 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index b740a832..5ecd212a 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -5,7 +5,6 @@ from aiogram import Router, F from aiogram.types import Message, CallbackQuery from aiogram.filters import Command from aiogram.fsm.context import FSMContext -from aiogram.exceptions import TelegramBadRequest from app.config import settings from app.database.database import get_db @@ -72,17 +71,7 @@ def _build_notification_settings_view(language: str): async def _render_notification_settings(callback: CallbackQuery) -> None: language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE) text, keyboard = _build_notification_settings_view(language) - try: - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) - except TelegramBadRequest as e: - if "message to edit" in str(e).lower(): - logger.warning( - "Не удалось изменить текст сообщения с настройками уведомлений, отправляем новое сообщение: %s", - e, - ) - await callback.message.answer(text, parse_mode="HTML", reply_markup=keyboard) - else: - raise + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) async def _render_notification_settings_for_state( @@ -105,28 +94,7 @@ async def _render_notification_settings_for_state( if business_connection_id: edit_kwargs["business_connection_id"] = business_connection_id - try: - await bot.edit_message_text(**edit_kwargs) - except TelegramBadRequest as e: - if "message to edit" in str(e).lower(): - logger.warning( - "Не удалось изменить текст сообщения (chat=%s, message=%s), отправляем новое: %s", - chat_id, - message_id, - e, - ) - send_kwargs = { - "chat_id": chat_id, - "text": text, - "parse_mode": "HTML", - "reply_markup": keyboard, - } - if business_connection_id: - send_kwargs["business_connection_id"] = business_connection_id - - await bot.send_message(**send_kwargs) - else: - raise + await bot.edit_message_text(**edit_kwargs) @router.callback_query(F.data == "admin_monitoring") @admin_required From da6d971de41f27249077626c4cb8832eec6daca0 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 09:18:11 +0300 Subject: [PATCH 051/146] Handle empty text edit errors for admin notification settings --- app/handlers/admin/monitoring.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 5ecd212a..bdc9b029 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -2,6 +2,7 @@ import asyncio import logging from datetime import datetime, timedelta from aiogram import Router, F +from aiogram.exceptions import TelegramBadRequest from aiogram.types import Message, CallbackQuery from aiogram.filters import Command from aiogram.fsm.context import FSMContext @@ -94,7 +95,23 @@ async def _render_notification_settings_for_state( if business_connection_id: edit_kwargs["business_connection_id"] = business_connection_id - await bot.edit_message_text(**edit_kwargs) + try: + await bot.edit_message_text(**edit_kwargs) + except TelegramBadRequest as error: + if "there is no text in the message to edit" in (error.message or "").lower(): + send_kwargs = { + "chat_id": chat_id, + "text": text, + "parse_mode": "HTML", + "reply_markup": keyboard, + } + + if business_connection_id: + send_kwargs["business_connection_id"] = business_connection_id + + await bot.send_message(**send_kwargs) + else: + raise @router.callback_query(F.data == "admin_monitoring") @admin_required From 2c28fda5e394635d81f0bb670d40ff72d49bec3c Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 09:20:08 +0300 Subject: [PATCH 052/146] Revert "Handle Telegram edit errors for admin notification settings" --- app/handlers/admin/monitoring.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index bdc9b029..5ecd212a 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -2,7 +2,6 @@ import asyncio import logging from datetime import datetime, timedelta from aiogram import Router, F -from aiogram.exceptions import TelegramBadRequest from aiogram.types import Message, CallbackQuery from aiogram.filters import Command from aiogram.fsm.context import FSMContext @@ -95,23 +94,7 @@ async def _render_notification_settings_for_state( if business_connection_id: edit_kwargs["business_connection_id"] = business_connection_id - try: - await bot.edit_message_text(**edit_kwargs) - except TelegramBadRequest as error: - if "there is no text in the message to edit" in (error.message or "").lower(): - send_kwargs = { - "chat_id": chat_id, - "text": text, - "parse_mode": "HTML", - "reply_markup": keyboard, - } - - if business_connection_id: - send_kwargs["business_connection_id"] = business_connection_id - - await bot.send_message(**send_kwargs) - else: - raise + await bot.edit_message_text(**edit_kwargs) @router.callback_query(F.data == "admin_monitoring") @admin_required From 7ba21d058cad71e2890f35bb5b540e98b8971bf8 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 09:20:27 +0300 Subject: [PATCH 053/146] Handle editing notification message without text --- app/handlers/admin/monitoring.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 5ecd212a..070bf1f8 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -5,6 +5,7 @@ from aiogram import Router, F from aiogram.types import Message, CallbackQuery from aiogram.filters import Command from aiogram.fsm.context import FSMContext +from aiogram.exceptions import TelegramBadRequest from app.config import settings from app.database.database import get_db @@ -94,7 +95,24 @@ async def _render_notification_settings_for_state( if business_connection_id: edit_kwargs["business_connection_id"] = business_connection_id - await bot.edit_message_text(**edit_kwargs) + try: + await bot.edit_message_text(**edit_kwargs) + except TelegramBadRequest as exc: + if "no text in the message to edit" in (exc.message or "").lower(): + caption_kwargs = { + "chat_id": chat_id, + "message_id": message_id, + "caption": text, + "parse_mode": "HTML", + "reply_markup": keyboard, + } + + if business_connection_id: + caption_kwargs["business_connection_id"] = business_connection_id + + await bot.edit_message_caption(**caption_kwargs) + else: + raise @router.callback_query(F.data == "admin_monitoring") @admin_required From 151b72baff48f0ee943c72b2c32e7c5fd28fc234 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 09:28:14 +0300 Subject: [PATCH 054/146] Add back button after updating notification settings --- app/handlers/admin/monitoring.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 070bf1f8..ab2c5b0a 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -2,7 +2,7 @@ import asyncio import logging from datetime import datetime, timedelta from aiogram import Router, F -from aiogram.types import Message, CallbackQuery +from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton from aiogram.filters import Command from aiogram.fsm.context import FSMContext from aiogram.exceptions import TelegramBadRequest @@ -680,7 +680,21 @@ async def process_notification_value_input(message: Message, state: FSMContext): await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова.")) return - await message.answer(texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены.")) + back_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.get("BACK", "⬅️ Назад"), + callback_data="admin_mon_notify_settings", + ) + ] + ] + ) + + await message.answer( + texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены."), + reply_markup=back_keyboard, + ) chat_id = data.get("settings_message_chat") message_id = data.get("settings_message_id") From 7380f4a15e9486ae3e23756e98435b007359f6e9 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 09:36:12 +0300 Subject: [PATCH 055/146] Add notification previews in monitoring settings --- app/handlers/admin/monitoring.py | 308 +++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index ab2c5b0a..e707b2ac 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -54,21 +54,249 @@ def _build_notification_settings_view(language: str): keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")], + [InlineKeyboardButton(text="🧪 Тест: 1 час после триала", callback_data="admin_mon_notify_preview_trial_1h")], [InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")], + [InlineKeyboardButton(text="🧪 Тест: 24 часа после триала", callback_data="admin_mon_notify_preview_trial_24h")], [InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")], + [InlineKeyboardButton(text="🧪 Тест: 1 день после истечения", callback_data="admin_mon_notify_preview_expired_1d")], [InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")], + [InlineKeyboardButton(text="🧪 Тест: скидка 2-3 день", callback_data="admin_mon_notify_preview_expired_2d")], [InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")], [InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")], [InlineKeyboardButton(text=f"{third_wave_status} • {third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")], + [InlineKeyboardButton(text="🧪 Тест: скидка спустя дни", callback_data="admin_mon_notify_preview_expired_nd")], [InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")], [InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")], [InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")], + [InlineKeyboardButton(text="🧪 Отправить все тесты", callback_data="admin_mon_notify_preview_all")], [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")], ]) return summary_text, keyboard +def _build_notification_preview_message(language: str, notification_type: str): + texts = get_texts(language) + now = datetime.now() + price_30_days = settings.format_price(settings.PRICE_30_DAYS) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + header = "🧪 Тестовое уведомление мониторинга\n\n" + + if notification_type == "trial_inactive_1h": + template = texts.get( + "TRIAL_INACTIVE_1H", + ( + "⏳ Прошёл час, а подключения нет\n\n" + "Если возникли сложности с запуском — воспользуйтесь инструкциями." + ), + ) + message = template.format( + price=price_30_days, + end_date=(now + timedelta(days=settings.TRIAL_DURATION_DAYS)).strftime("%d.%m.%Y %H:%M"), + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), + callback_data="menu_subscription", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), + callback_data="menu_support", + ) + ], + ] + ) + elif notification_type == "trial_inactive_24h": + template = texts.get( + "TRIAL_INACTIVE_24H", + ( + "⏳ Вы ещё не подключились к VPN\n\n" + "Прошли сутки с активации тестового периода, но трафик не зафиксирован." + "\n\nНажмите кнопку ниже, чтобы подключиться." + ), + ) + message = template.format( + price=price_30_days, + end_date=(now + timedelta(days=1)).strftime("%d.%m.%Y %H:%M"), + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), + callback_data="menu_subscription", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), + callback_data="menu_support", + ) + ], + ] + ) + elif notification_type == "expired_1d": + template = texts.get( + "SUBSCRIPTION_EXPIRED_1D", + ( + "⛔ Подписка закончилась\n\n" + "Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис." + ), + ) + message = template.format( + end_date=(now - timedelta(days=1)).strftime("%d.%m.%Y %H:%M"), + price=price_30_days, + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), + callback_data="subscription_extend", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), + callback_data="balance_topup", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), + callback_data="menu_support", + ) + ], + ] + ) + elif notification_type == "expired_2d": + percent = NotificationSettingsService.get_second_wave_discount_percent() + valid_hours = NotificationSettingsService.get_second_wave_valid_hours() + bonus_amount = settings.PRICE_30_DAYS * percent // 100 + template = texts.get( + "SUBSCRIPTION_EXPIRED_SECOND_WAVE", + ( + "🔥 Скидка {percent}% на продление\n\n" + "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. " + "Предложение действует до {expires_at}." + ), + ) + message = template.format( + percent=percent, + bonus=settings.format_price(bonus_amount), + expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"), + trigger_days=3, + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🎁 Получить скидку", + callback_data="claim_discount_preview", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), + callback_data="subscription_extend", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), + callback_data="balance_topup", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), + callback_data="menu_support", + ) + ], + ] + ) + elif notification_type == "expired_nd": + percent = NotificationSettingsService.get_third_wave_discount_percent() + valid_hours = NotificationSettingsService.get_third_wave_valid_hours() + trigger_days = NotificationSettingsService.get_third_wave_trigger_days() + bonus_amount = settings.PRICE_30_DAYS * percent // 100 + template = texts.get( + "SUBSCRIPTION_EXPIRED_THIRD_WAVE", + ( + "🎁 Индивидуальная скидка {percent}%\n\n" + "Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. " + "Скидка действует до {expires_at}." + ), + ) + message = template.format( + percent=percent, + bonus=settings.format_price(bonus_amount), + trigger_days=trigger_days, + expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"), + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🎁 Получить скидку", + callback_data="claim_discount_preview", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), + callback_data="subscription_extend", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), + callback_data="balance_topup", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), + callback_data="menu_support", + ) + ], + ] + ) + else: + raise ValueError(f"Unsupported notification type: {notification_type}") + + footer = "\n\nСообщение отправлено только вам для проверки оформления." + return header + message + footer, keyboard + + +async def _send_notification_preview(bot, chat_id: int, language: str, notification_type: str) -> None: + message, keyboard = _build_notification_preview_message(language, notification_type) + await bot.send_message( + chat_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + + async def _render_notification_settings(callback: CallbackQuery) -> None: language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE) text, keyboard = _build_notification_settings_view(language) @@ -200,6 +428,18 @@ async def toggle_trial_1h_notification(callback: CallbackQuery): await _render_notification_settings(callback) +@router.callback_query(F.data == "admin_mon_notify_preview_trial_1h") +@admin_required +async def preview_trial_1h_notification(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_1h") + await callback.answer("✅ Пример отправлен") + except Exception as exc: + logger.error("Failed to send trial 1h preview: %s", exc) + await callback.answer("❌ Не удалось отправить тест", show_alert=True) + + @router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h") @admin_required async def toggle_trial_24h_notification(callback: CallbackQuery): @@ -209,6 +449,18 @@ async def toggle_trial_24h_notification(callback: CallbackQuery): await _render_notification_settings(callback) +@router.callback_query(F.data == "admin_mon_notify_preview_trial_24h") +@admin_required +async def preview_trial_24h_notification(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_24h") + await callback.answer("✅ Пример отправлен") + except Exception as exc: + logger.error("Failed to send trial 24h preview: %s", exc) + await callback.answer("❌ Не удалось отправить тест", show_alert=True) + + @router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d") @admin_required async def toggle_expired_1d_notification(callback: CallbackQuery): @@ -218,6 +470,18 @@ async def toggle_expired_1d_notification(callback: CallbackQuery): await _render_notification_settings(callback) +@router.callback_query(F.data == "admin_mon_notify_preview_expired_1d") +@admin_required +async def preview_expired_1d_notification(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_1d") + await callback.answer("✅ Пример отправлен") + except Exception as exc: + logger.error("Failed to send expired 1d preview: %s", exc) + await callback.answer("❌ Не удалось отправить тест", show_alert=True) + + @router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d") @admin_required async def toggle_second_wave_notification(callback: CallbackQuery): @@ -227,6 +491,18 @@ async def toggle_second_wave_notification(callback: CallbackQuery): await _render_notification_settings(callback) +@router.callback_query(F.data == "admin_mon_notify_preview_expired_2d") +@admin_required +async def preview_second_wave_notification(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_2d") + await callback.answer("✅ Пример отправлен") + except Exception as exc: + logger.error("Failed to send second wave preview: %s", exc) + await callback.answer("❌ Не удалось отправить тест", show_alert=True) + + @router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd") @admin_required async def toggle_third_wave_notification(callback: CallbackQuery): @@ -236,6 +512,38 @@ async def toggle_third_wave_notification(callback: CallbackQuery): await _render_notification_settings(callback) +@router.callback_query(F.data == "admin_mon_notify_preview_expired_nd") +@admin_required +async def preview_third_wave_notification(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_nd") + await callback.answer("✅ Пример отправлен") + except Exception as exc: + logger.error("Failed to send third wave preview: %s", exc) + await callback.answer("❌ Не удалось отправить тест", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_preview_all") +@admin_required +async def preview_all_notifications(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + chat_id = callback.from_user.id + for notification_type in [ + "trial_inactive_1h", + "trial_inactive_24h", + "expired_1d", + "expired_2d", + "expired_nd", + ]: + await _send_notification_preview(callback.bot, chat_id, language, notification_type) + await callback.answer("✅ Все тестовые уведомления отправлены") + except Exception as exc: + logger.error("Failed to send all notification previews: %s", exc) + await callback.answer("❌ Не удалось отправить тесты", show_alert=True) + + async def _start_notification_value_edit( callback: CallbackQuery, state: FSMContext, From 09810d0b6c2280cdbac438cd619052f4696d7b2c Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 09:48:35 +0300 Subject: [PATCH 056/146] Add logo support to monitoring notifications --- app/services/monitoring_service.py | 106 +++++++++++++++++++++-------- 1 file changed, 77 insertions(+), 29 deletions(-) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 9225be24..1f7680f5 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -1,9 +1,11 @@ import asyncio import logging from datetime import datetime, timedelta +from pathlib import Path from typing import Dict, List, Any, Optional, Set from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from aiogram.types import FSInputFile from sqlalchemy import select, and_, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -47,6 +49,9 @@ from app.external.remnawave_api import ( logger = logging.getLogger(__name__) +LOGO_PATH = Path(settings.LOGO_FILE) + + class MonitoringService: def __init__(self, bot=None): @@ -58,6 +63,45 @@ class MonitoringService: self._last_cleanup = datetime.utcnow() self._sla_task = None + async def _send_message_with_logo( + self, + chat_id: int, + text: str, + reply_markup=None, + parse_mode: Optional[str] = "HTML", + ): + """Отправляет сообщение, добавляя логотип при необходимости.""" + if not self.bot: + raise RuntimeError("Bot instance is not available") + + if ( + settings.ENABLE_LOGO_MODE + and LOGO_PATH.exists() + and (text is None or len(text) <= 1000) + ): + try: + return await self.bot.send_photo( + chat_id=chat_id, + photo=FSInputFile(LOGO_PATH), + caption=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + except TelegramBadRequest as exc: + logger.warning( + "Не удалось отправить сообщение с логотипом пользователю %s: %s. " + "Отправляем текстовое сообщение.", + chat_id, + exc, + ) + + return await self.bot.send_message( + chat_id=chat_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + @staticmethod def _is_unreachable_error(error: TelegramBadRequest) -> bool: message = str(error).lower() @@ -654,12 +698,12 @@ class MonitoringService: [InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")], [InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")] ]) - - await self.bot.send_message( - user.telegram_id, - message, + + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, parse_mode="HTML", - reply_markup=keyboard + reply_markup=keyboard, ) return True @@ -711,12 +755,12 @@ class MonitoringService: [InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")], [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")] ]) - - await self.bot.send_message( - user.telegram_id, - message, + + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, parse_mode="HTML", - reply_markup=keyboard + reply_markup=keyboard, ) return True @@ -764,12 +808,12 @@ class MonitoringService: [InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")], [InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup")] ]) - - await self.bot.send_message( - user.telegram_id, - message, + + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, parse_mode="HTML", - reply_markup=keyboard + reply_markup=keyboard, ) return True @@ -824,9 +868,9 @@ class MonitoringService: [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], ]) - await self.bot.send_message( - user.telegram_id, - message, + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, parse_mode="HTML", reply_markup=keyboard, ) @@ -872,9 +916,9 @@ class MonitoringService: [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], ]) - await self.bot.send_message( - user.telegram_id, - message, + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, parse_mode="HTML", reply_markup=keyboard, ) @@ -946,9 +990,9 @@ class MonitoringService: [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], ]) - await self.bot.send_message( - user.telegram_id, - message, + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, parse_mode="HTML", reply_markup=keyboard, ) @@ -978,7 +1022,11 @@ class MonitoringService: days=days, amount=settings.format_price(amount) ) - await self.bot.send_message(user.telegram_id, message, parse_mode="HTML") + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, + parse_mode="HTML", + ) except (TelegramForbiddenError, TelegramBadRequest) as exc: if not self._handle_unreachable_user(user, exc, "уведомление об успешном автоплатеже"): logger.error( @@ -1008,11 +1056,11 @@ class MonitoringService: [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")] ]) - await self.bot.send_message( - user.telegram_id, - message, + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, parse_mode="HTML", - reply_markup=keyboard + reply_markup=keyboard, ) except (TelegramForbiddenError, TelegramBadRequest) as exc: From a6465b0b0bd05ea54205dee9a002c2682c8fb419 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 17:18:12 +0300 Subject: [PATCH 057/146] Tweak server edit menu promo group display --- app/database/crud/promo_group.py | 7 + app/database/crud/server_squad.py | 136 +++++++++++++++-- app/database/models.py | 32 +++- app/database/universal_migration.py | 104 +++++++++++++ app/handlers/admin/servers.py | 221 ++++++++++++++++++++++++++-- app/handlers/subscription.py | 105 +++++++------ app/states.py | 1 + app/utils/cache.py | 6 + 8 files changed, 540 insertions(+), 72 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 3bc093f2..2e372b84 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -44,6 +44,13 @@ async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[Pro return await db.get(PromoGroup, group_id) +async def get_all_promo_groups(db: AsyncSession) -> List[PromoGroup]: + result = await db.execute( + select(PromoGroup).order_by(PromoGroup.is_default.desc(), PromoGroup.name) + ) + return result.scalars().all() + + async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]: result = await db.execute( select(PromoGroup).where(PromoGroup.is_default.is_(True)) diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index eb0692e6..b351c4ad 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -4,7 +4,13 @@ from sqlalchemy import select, and_, func, update, delete, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import ServerSquad, SubscriptionServer, Subscription +from app.database.models import ( + ServerSquad, + SubscriptionServer, + Subscription, + PromoGroup, + server_squad_promo_groups, +) logger = logging.getLogger(__name__) @@ -18,9 +24,43 @@ async def create_server_squad( price_kopeks: int = 0, description: str = None, max_users: int = None, - is_available: bool = True + is_available: bool = True, + promo_group_ids: Optional[List[int]] = None, ) -> ServerSquad: - + result = await db.execute( + select(PromoGroup.id) + .where(PromoGroup.is_default.is_(True)) + .limit(1) + ) + default_group_id = result.scalar_one_or_none() + + if promo_group_ids is None: + promo_group_ids = [] + if default_group_id is not None: + promo_group_ids.append(default_group_id) + else: + fallback_group_result = await db.execute( + select(PromoGroup.id) + .order_by(PromoGroup.is_default.desc(), PromoGroup.id) + .limit(1) + ) + fallback_group_id = fallback_group_result.scalar_one_or_none() + if fallback_group_id is not None: + promo_group_ids.append(fallback_group_id) + + unique_group_ids = list(dict.fromkeys(promo_group_ids or [])) + + if not unique_group_ids: + raise ValueError("Server squad must have at least one promo group") + + groups_result = await db.execute( + select(PromoGroup).where(PromoGroup.id.in_(unique_group_ids)) + ) + groups = groups_result.scalars().all() + + if len(groups) != len(unique_group_ids): + raise ValueError("One or more promo groups not found") + server_squad = ServerSquad( squad_uuid=squad_uuid, display_name=display_name, @@ -31,12 +71,21 @@ async def create_server_squad( max_users=max_users, is_available=is_available ) - + db.add(server_squad) + await db.flush() + + server_squad.promo_groups = groups + await db.commit() await db.refresh(server_squad) - - logger.info(f"✅ Создан сервер {display_name} (UUID: {squad_uuid})") + + logger.info( + "✅ Создан сервер %s (UUID: %s) с промогруппами: %s", + display_name, + squad_uuid, + ", ".join(group.name for group in groups), + ) return server_squad @@ -46,7 +95,9 @@ async def get_server_squad_by_uuid( ) -> Optional[ServerSquad]: result = await db.execute( - select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid) + select(ServerSquad) + .options(selectinload(ServerSquad.promo_groups)) + .where(ServerSquad.squad_uuid == squad_uuid) ) return result.scalar_one_or_none() @@ -57,7 +108,9 @@ async def get_server_squad_by_id( ) -> Optional[ServerSquad]: result = await db.execute( - select(ServerSquad).where(ServerSquad.id == server_id) + select(ServerSquad) + .options(selectinload(ServerSquad.promo_groups)) + .where(ServerSquad.id == server_id) ) return result.scalar_one_or_none() @@ -69,7 +122,7 @@ async def get_all_server_squads( limit: int = 50 ) -> Tuple[List[ServerSquad], int]: - query = select(ServerSquad) + query = select(ServerSquad).options(selectinload(ServerSquad.promo_groups)) if available_only: query = query.where(ServerSquad.is_available == True) @@ -91,16 +144,75 @@ async def get_all_server_squads( return servers, total_count -async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]: +async def get_available_server_squads( + db: AsyncSession, + promo_group_id: Optional[int] = None, +) -> List[ServerSquad]: - result = await db.execute( + query = ( select(ServerSquad) + .options(selectinload(ServerSquad.promo_groups)) .where(ServerSquad.is_available == True) - .order_by(ServerSquad.sort_order, ServerSquad.display_name) ) + + if promo_group_id is not None: + query = ( + query.join( + server_squad_promo_groups, + server_squad_promo_groups.c.server_squad_id == ServerSquad.id, + ) + .where(server_squad_promo_groups.c.promo_group_id == promo_group_id) + .distinct() + ) + + query = query.order_by(ServerSquad.sort_order, ServerSquad.display_name) + + result = await db.execute(query) return result.scalars().all() +async def set_server_squad_promo_groups( + db: AsyncSession, + server_id: int, + promo_group_ids: List[int], +) -> Optional[ServerSquad]: + + unique_group_ids = list(dict.fromkeys(promo_group_ids or [])) + + if not unique_group_ids: + logger.warning("Попытка оставить сервер без промогрупп (id=%s)", server_id) + return None + + server = await get_server_squad_by_id(db, server_id) + if not server: + logger.warning("Сервер %s не найден при обновлении промогрупп", server_id) + return None + + groups_result = await db.execute( + select(PromoGroup).where(PromoGroup.id.in_(unique_group_ids)) + ) + groups = groups_result.scalars().all() + + if len(groups) != len(unique_group_ids): + logger.warning( + "Не все промогруппы найдены для сервера %s: %s", + server_id, + unique_group_ids, + ) + return None + + server.promo_groups = groups + await db.commit() + await db.refresh(server) + + logger.info( + "✅ Обновлены промогруппы сервера %s: %s", + server.display_name, + ", ".join(group.name for group in groups), + ) + return server + + async def update_server_squad( db: AsyncSession, server_id: int, diff --git a/app/database/models.py b/app/database/models.py index 91a7a360..f0fe6671 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -15,6 +15,7 @@ from sqlalchemy import ( BigInteger, UniqueConstraint, Index, + Table, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -24,6 +25,22 @@ from sqlalchemy.sql import func Base = declarative_base() +server_squad_promo_groups = Table( + "server_squad_promo_groups", + Base.metadata, + Column( + "server_squad_id", + ForeignKey("server_squads.id", ondelete="CASCADE"), + primary_key=True, + ), + Column( + "promo_group_id", + ForeignKey("promo_groups.id", ondelete="CASCADE"), + primary_key=True, + ), +) + + class UserStatus(Enum): ACTIVE = "active" BLOCKED = "blocked" @@ -278,6 +295,11 @@ class PromoGroup(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) users = relationship("User", back_populates="promo_group") + server_squads = relationship( + "ServerSquad", + secondary="server_squad_promo_groups", + back_populates="promo_groups", + ) def _get_period_discounts_map(self) -> Dict[int, int]: raw_discounts = self.period_discounts or {} @@ -832,10 +854,16 @@ class ServerSquad(Base): sort_order = Column(Integer, default=0) max_users = Column(Integer, nullable=True) - current_users = Column(Integer, default=0) - + current_users = Column(Integer, default=0) + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + promo_groups = relationship( + "PromoGroup", + secondary="server_squad_promo_groups", + back_populates="server_squads", + ) @property def price_rubles(self) -> float: diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 522747f0..215675d5 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -608,6 +608,96 @@ async def create_discount_offers_table(): logger.error(f"Ошибка создания таблицы discount_offers: {e}") return False + +async def create_server_squad_promo_groups_table(): + table_exists = await check_table_exists('server_squad_promo_groups') + if table_exists: + logger.info("Таблица server_squad_promo_groups уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text(""" + CREATE TABLE server_squad_promo_groups ( + server_squad_id INTEGER NOT NULL, + promo_group_id INTEGER NOT NULL, + PRIMARY KEY (server_squad_id, promo_group_id), + FOREIGN KEY(server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, + FOREIGN KEY(promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE + ) + """)) + + elif db_type == 'postgresql': + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS server_squad_promo_groups ( + server_squad_id INTEGER NOT NULL REFERENCES server_squads(id) ON DELETE CASCADE, + promo_group_id INTEGER NOT NULL REFERENCES promo_groups(id) ON DELETE CASCADE, + PRIMARY KEY (server_squad_id, promo_group_id) + ) + """)) + + elif db_type == 'mysql': + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS server_squad_promo_groups ( + server_squad_id INTEGER NOT NULL, + promo_group_id INTEGER NOT NULL, + PRIMARY KEY (server_squad_id, promo_group_id), + CONSTRAINT fk_sspg_server FOREIGN KEY(server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, + CONSTRAINT fk_sspg_group FOREIGN KEY(promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE + ) + """)) + + else: + raise ValueError(f"Unsupported database type: {db_type}") + + logger.info("✅ Таблица server_squad_promo_groups успешно создана") + return True + + except Exception as e: + logger.error(f"Ошибка создания таблицы server_squad_promo_groups: {e}") + return False + + +async def ensure_server_squads_have_default_promo_group(): + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + default_group_sql = "SELECT id FROM promo_groups WHERE is_default IS TRUE LIMIT 1" + if db_type in {'sqlite', 'mysql'}: + default_group_sql = "SELECT id FROM promo_groups WHERE is_default = 1 LIMIT 1" + + result = await conn.execute(text(default_group_sql)) + row = result.fetchone() + + if not row: + logger.warning("⚠️ Базовая промогруппа не найдена, пропускаем привязку серверов") + return False + + default_group_id = row[0] + + await conn.execute( + text(""" + INSERT INTO server_squad_promo_groups (server_squad_id, promo_group_id) + SELECT ss.id, :group_id + FROM server_squads ss + LEFT JOIN server_squad_promo_groups spg + ON spg.server_squad_id = ss.id + WHERE spg.server_squad_id IS NULL + """), + {"group_id": default_group_id}, + ) + + logger.info("✅ Все серверы без привязки получили базовую промогруппу") + return True + + except Exception as e: + logger.error(f"Ошибка назначения базовой промогруппы серверам: {e}") + return False + async def create_user_messages_table(): table_exists = await check_table_exists('user_messages') if table_exists: @@ -1562,6 +1652,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей discount_offers") + logger.info("=== СОЗДАНИЕ СВЯЗИ SERVER_SQUAD_PROMO_GROUPS ===") + promo_link_created = await create_server_squad_promo_groups_table() + if promo_link_created: + logger.info("✅ Таблица server_squad_promo_groups готова") + else: + logger.warning("⚠️ Проблемы с таблицей server_squad_promo_groups") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===") user_messages_created = await create_user_messages_table() if user_messages_created: @@ -1569,6 +1666,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей user_messages") + logger.info("=== НАЗНАЧЕНИЕ БАЗОВОЙ ПРОМОГРУППЫ СЕРВЕРАМ ===") + default_assignment_done = await ensure_server_squads_have_default_promo_group() + if default_assignment_done: + logger.info("✅ Базовая промогруппа назначена серверам без связей") + else: + logger.warning("⚠️ Не удалось назначить базовую промогруппу серверам") + logger.info("=== СОЗДАНИЕ/ОБНОВЛЕНИЕ ТАБЛИЦЫ WELCOME_TEXTS ===") welcome_texts_created = await create_welcome_texts_table() if welcome_texts_created: diff --git a/app/handlers/admin/servers.py b/app/handlers/admin/servers.py index f5ae0023..58c394e3 100644 --- a/app/handlers/admin/servers.py +++ b/app/handlers/admin/servers.py @@ -1,4 +1,6 @@ import logging +from typing import List, Set + from aiogram import Dispatcher, types, F from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -6,17 +8,61 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.states import AdminStates from app.database.models import User from app.database.crud.server_squad import ( - get_all_server_squads, get_server_squad_by_id, update_server_squad, - delete_server_squad, sync_with_remnawave, get_server_statistics, - create_server_squad, get_available_server_squads + get_all_server_squads, + get_server_squad_by_id, + update_server_squad, + delete_server_squad, + sync_with_remnawave, + get_server_statistics, + create_server_squad, + get_available_server_squads, + set_server_squad_promo_groups, ) +from app.database.crud.promo_group import get_all_promo_groups from app.services.remnawave_service import RemnaWaveService from app.utils.decorators import admin_required, error_handler -from app.utils.cache import cache +from app.utils.cache import invalidate_available_countries_cache logger = logging.getLogger(__name__) +def _format_promo_group_list(promo_groups: List, selected_ids: Set[int]) -> str: + names = [group.name for group in promo_groups if group.id in selected_ids] + return ", ".join(names) if names else "Не выбраны" + + +def _build_server_promo_groups_keyboard( + server_id: int, + promo_groups: List, + selected_ids: Set[int], +): + rows = [] + + for group in promo_groups: + emoji = "✅" if group.id in selected_ids else "⚪" + rows.append([ + types.InlineKeyboardButton( + text=f"{emoji} {group.name}", + callback_data=f"admin_server_group_toggle_{server_id}_{group.id}", + ) + ]) + + rows.append([ + types.InlineKeyboardButton( + text="💾 Сохранить", + callback_data=f"admin_server_group_save_{server_id}", + ) + ]) + rows.append([ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data=f"admin_server_edit_{server_id}", + ) + ]) + + return types.InlineKeyboardMarkup(inline_keyboard=rows) + + @admin_required @error_handler async def show_servers_menu( @@ -166,7 +212,7 @@ async def sync_servers_with_remnawave( created, updated, disabled = await sync_with_remnawave(db, squads) - await cache.delete("available_countries") + await invalidate_available_countries_cache() text = f""" ✅ Синхронизация завершена @@ -223,6 +269,7 @@ async def show_server_edit_menu( status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" + promo_group_names = ", ".join(group.name for group in server.promo_groups) if server.promo_groups else "Не назначены" text = f""" 🌐 Редактирование сервера @@ -239,13 +286,14 @@ async def show_server_edit_menu( • Код страны: {server.country_code or 'Не указан'} • Лимит пользователей: {server.max_users or 'Без лимита'} • Текущих пользователей: {server.current_users} +• Промогруппы: {promo_group_names} Описание: {server.description or 'Не указано'} Выберите что изменить: """ - + keyboard = [ [ types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"), @@ -258,6 +306,9 @@ async def show_server_edit_menu( [ types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}") ], + [ + types.InlineKeyboardButton(text="🎯 Промогруппы", callback_data=f"admin_server_edit_groups_{server.id}") + ], [ types.InlineKeyboardButton( text="❌ Отключить" if server.is_available else "✅ Включить", @@ -296,7 +347,7 @@ async def toggle_server_availability( new_status = not server.is_available await update_server_squad(db, server_id, is_available=new_status) - await cache.delete("available_countries") + await invalidate_available_countries_cache() status_text = "включен" if new_status else "отключен" await callback.answer(f"✅ Сервер {status_text}!") @@ -321,6 +372,7 @@ async def toggle_server_availability( • Код страны: {server.country_code or 'Не указан'} • Лимит пользователей: {server.max_users or 'Без лимита'} • Текущих пользователей: {server.current_users} +• Промогруппы: {promo_group_names} Описание: {server.description or 'Не указано'} @@ -340,6 +392,9 @@ async def toggle_server_availability( [ types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}") ], + [ + types.InlineKeyboardButton(text="🎯 Промогруппы", callback_data=f"admin_server_edit_groups_{server.id}") + ], [ types.InlineKeyboardButton( text="❌ Отключить" if server.is_available else "✅ Включить", @@ -422,7 +477,7 @@ async def process_server_price_edit( if server: await state.clear() - await cache.delete("available_countries") + await invalidate_available_countries_cache() price_text = f"{int(price_rubles)} ₽" if price_kopeks > 0 else "Бесплатно" await message.answer( @@ -497,7 +552,7 @@ async def process_server_name_edit( if server: await state.clear() - await cache.delete("available_countries") + await invalidate_available_countries_cache() await message.answer( f"✅ Название сервера изменено на: {new_name}", @@ -570,7 +625,7 @@ async def delete_server_execute( success = await delete_server_squad(db, server_id) if success: - await cache.delete("available_countries") + await invalidate_available_countries_cache() await callback.message.edit_text( f"✅ Сервер {server.display_name} успешно удален!", @@ -701,7 +756,7 @@ async def process_server_country_edit( if server: await state.clear() - await cache.delete("available_countries") + await invalidate_available_countries_cache() country_text = new_country or "Удален" await message.answer( @@ -862,6 +917,137 @@ async def process_server_description_edit( else: await message.answer("❌ Ошибка при обновлении сервера") + +@admin_required +@error_handler +async def start_server_edit_promo_groups( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession, +): + + server_id = int(callback.data.split('_')[-1]) + server = await get_server_squad_by_id(db, server_id) + + if not server: + await callback.answer("❌ Сервер не найден!", show_alert=True) + return + + promo_groups = await get_all_promo_groups(db) + if not promo_groups: + await callback.answer("⚠️ Нет доступных промогрупп", show_alert=True) + return + + selected_ids: Set[int] = {group.id for group in server.promo_groups} + + await state.set_data({ + 'server_id': server_id, + 'server_name': server.display_name, + 'selected_promo_groups': list(selected_ids), + }) + await state.set_state(AdminStates.editing_server_promo_groups) + + selected_text = _format_promo_group_list(promo_groups, selected_ids) + text = ( + f"🎯 Промогруппы сервера\n\n" + f"Сервер: {server.display_name}\n" + f"Текущие промогруппы: {selected_text}\n\n" + "Выберите промогруппы, которым будет доступен сервер." + ) + + await callback.message.edit_text( + text, + reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_server_promo_group( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession, +): + + parts = callback.data.split('_') + server_id = int(parts[-2]) + group_id = int(parts[-1]) + + data = await state.get_data() + selected_ids: Set[int] = set(data.get('selected_promo_groups', [])) + + if group_id in selected_ids: + if len(selected_ids) == 1: + await callback.answer("⚠️ Должна быть выбрана хотя бы одна промогруппа", show_alert=True) + return + selected_ids.remove(group_id) + else: + selected_ids.add(group_id) + + data['selected_promo_groups'] = list(selected_ids) + await state.set_data(data) + + promo_groups = await get_all_promo_groups(db) + selected_text = _format_promo_group_list(promo_groups, selected_ids) + + await callback.message.edit_text( + f"🎯 Промогруппы сервера\n\n" + f"Сервер: {data.get('server_name', 'Неизвестно')}\n" + f"Текущие промогруппы: {selected_text}\n\n" + "Выберите промогруппы, которым будет доступен сервер.", + reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids), + parse_mode="HTML", + ) + + await callback.answer() + + +@admin_required +@error_handler +async def save_server_promo_groups( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession, +): + + data = await state.get_data() + server_id_value = data.get('server_id') + if server_id_value is None: + await callback.answer("❌ Не удалось определить сервер", show_alert=True) + return + + server_id = int(server_id_value) + selected_ids: List[int] = data.get('selected_promo_groups', []) + + if not selected_ids: + await callback.answer("⚠️ Выберите хотя бы одну промогруппу", show_alert=True) + return + + server = await set_server_squad_promo_groups(db, server_id, selected_ids) + + if not server: + await callback.answer("❌ Не удалось сохранить промогруппы", show_alert=True) + return + + await state.clear() + await invalidate_available_countries_cache() + + promo_group_names = ", ".join(group.name for group in server.promo_groups) if server.promo_groups else "Не назначены" + + await callback.message.edit_text( + f"✅ Промогруппы сервера обновлены:\n{promo_group_names}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")] + ]), + parse_mode="HTML", + ) + await callback.answer() + @admin_required @error_handler async def sync_server_user_counts_handler( @@ -940,13 +1126,16 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_")) dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_")) dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_")) - dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_")) - dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_")) - + dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_")) + dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_")) + dp.callback_query.register(start_server_edit_promo_groups, F.data.startswith("admin_server_edit_groups_")) + dp.callback_query.register(toggle_server_promo_group, F.data.startswith("admin_server_group_toggle_")) + dp.callback_query.register(save_server_promo_groups, F.data.startswith("admin_server_group_save_")) + dp.message.register(process_server_name_edit, AdminStates.editing_server_name) dp.message.register(process_server_price_edit, AdminStates.editing_server_price) - dp.message.register(process_server_country_edit, AdminStates.editing_server_country) - dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit) + dp.message.register(process_server_country_edit, AdminStates.editing_server_country) + dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit) dp.message.register(process_server_description_edit, AdminStates.editing_server_description) dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm")) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 3eeee497..1616e9ba 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -99,7 +99,7 @@ async def _prepare_subscription_summary( ) summary_data = dict(data) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) months_in_period = calculate_months_from_days(summary_data['period_days']) period_display = format_period_description(summary_data['period_days'], db_user.language) @@ -1003,7 +1003,7 @@ async def return_to_saved_cart( from app.utils.pricing_utils import calculate_months_from_days, format_period_description - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) selected_countries_names = [] months_in_period = calculate_months_from_days(data['period_days']) @@ -1043,7 +1043,7 @@ async def handle_add_countries( db: AsyncSession, state: FSMContext ): - if not await _should_show_countries_management(): + if not await _should_show_countries_management(db_user.promo_group_id): await callback.answer("ℹ️ Управление серверами недоступно - доступен только один сервер", show_alert=True) return @@ -1054,7 +1054,7 @@ async def handle_add_countries( await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True) return - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) current_countries = subscription.connected_squads current_countries_names = [] @@ -1138,21 +1138,26 @@ async def handle_manage_country( return data = await state.get_data() + countries = await _get_available_countries(db_user.promo_group_id) + available_ids = {country['uuid'] for country in countries} + + if country_uuid not in available_ids: + await callback.answer("❌ Эта страна недоступна для вашей промогруппы", show_alert=True) + return + current_selected = data.get('countries', subscription.connected_squads.copy()) - + if country_uuid in current_selected: current_selected.remove(country_uuid) action = "removed" else: current_selected.append(country_uuid) action = "added" - + logger.info(f"🔍 Страна {country_uuid} {action}") - + await state.update_data(countries=current_selected) - countries = await _get_available_countries() - try: await callback.message.edit_reply_markup( reply_markup=get_manage_countries_keyboard( @@ -1203,7 +1208,7 @@ async def apply_countries_changes( logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) months_to_pay = get_remaining_months(subscription.end_date) @@ -2527,15 +2532,15 @@ async def select_period( ) await state.set_state(SubscriptionStates.selecting_traffic) else: - if await _should_show_countries_management(): - countries = await _get_available_countries() + if await _should_show_countries_management(db_user.promo_group_id): + countries = await _get_available_countries(db_user.promo_group_id) await callback.message.edit_text( texts.SELECT_COUNTRIES, reply_markup=get_countries_keyboard(countries, [], db_user.language) ) await state.set_state(SubscriptionStates.selecting_countries) else: - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) @@ -2603,7 +2608,7 @@ async def get_traffic_packages_info() -> str: async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession): devices_used = await get_current_devices_count(db_user) - countries_info = await _get_countries_info(subscription.connected_squads) + countries_info = await _get_countries_info(subscription.connected_squads, db_user.promo_group_id) countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет" subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..." @@ -2683,15 +2688,15 @@ async def select_traffic( await state.set_data(data) - if await _should_show_countries_management(): - countries = await _get_available_countries() + if await _should_show_countries_management(db_user.promo_group_id): + countries = await _get_available_countries(db_user.promo_group_id) await callback.message.edit_text( texts.SELECT_COUNTRIES, reply_markup=get_countries_keyboard(countries, [], db_user.language) ) await state.set_state(SubscriptionStates.selecting_countries) else: - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) @@ -2717,13 +2722,19 @@ async def select_country( data = await state.get_data() selected_countries = data.get('countries', []) + + countries = await _get_available_countries(db_user.promo_group_id) + available_ids = {country['uuid'] for country in countries} + + if country_uuid not in available_ids: + await callback.answer("❌ Эта страна недоступна для вашей промогруппы", show_alert=True) + return + if country_uuid in selected_countries: selected_countries.remove(country_uuid) else: selected_countries.append(country_uuid) - countries = await _get_available_countries() - period_base_price = PERIOD_PRICES[data['period_days']] from app.utils.pricing_utils import apply_percentage_discount @@ -2797,7 +2808,7 @@ async def select_devices( settings.get_traffic_price(data['traffic_gb']) ) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) countries_price = sum( c['price_kopeks'] for c in countries if c['uuid'] in data['countries'] @@ -2866,7 +2877,7 @@ async def confirm_purchase( else None ) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) months_in_period = data.get( 'months_in_period', calculate_months_from_days(data['period_days']) @@ -3523,7 +3534,7 @@ async def handle_subscription_settings( Выберите что хотите изменить: """ - show_countries = await _should_show_countries_management() + show_countries = await _should_show_countries_management(db_user.promo_group_id) await callback.message.edit_text( settings_text, @@ -3636,8 +3647,8 @@ async def handle_subscription_config_back( await state.set_state(SubscriptionStates.selecting_period) elif current_state == SubscriptionStates.selecting_devices.state: - if await _should_show_countries_management(): - countries = await _get_available_countries() + if await _should_show_countries_management(db_user.promo_group_id): + countries = await _get_available_countries(db_user.promo_group_id) data = await state.get_data() selected_countries = data.get('countries', []) @@ -3683,19 +3694,24 @@ async def handle_subscription_cancel( await callback.answer("❌ Покупка отменена") -async def _get_available_countries(): - from app.utils.cache import cache +async def _get_available_countries(promo_group_id: Optional[int] = None): + from app.utils.cache import cache, cache_key from app.database.database import AsyncSessionLocal from app.database.crud.server_squad import get_available_server_squads - - cached_countries = await cache.get("available_countries") + + cache_key_name = cache_key("available_countries", promo_group_id or "all") + + cached_countries = await cache.get(cache_key_name) if cached_countries: return cached_countries - + try: async with AsyncSessionLocal() as db: - available_servers = await get_available_server_squads(db) - + available_servers = await get_available_server_squads( + db, + promo_group_id=promo_group_id, + ) + countries = [] for server in available_servers: countries.append({ @@ -3734,20 +3750,20 @@ async def _get_available_countries(): "is_available": True }) - await cache.set("available_countries", countries, 300) + await cache.set(cache_key_name, countries, 300) return countries - + except Exception as e: logger.error(f"Ошибка получения списка стран: {e}") fallback_countries = [ {"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True}, ] - - await cache.set("available_countries", fallback_countries, 60) + + await cache.set(cache_key_name, fallback_countries, 60) return fallback_countries -async def _get_countries_info(squad_uuids): - countries = await _get_available_countries() +async def _get_countries_info(squad_uuids, promo_group_id: Optional[int] = None): + countries = await _get_available_countries(promo_group_id) return [c for c in countries if c['uuid'] in squad_uuids] async def handle_reset_devices( @@ -3776,8 +3792,13 @@ async def handle_add_country_to_subscription( logger.info(f"🔍 Данные состояния: {data}") selected_countries = data.get('countries', []) - countries = await _get_available_countries() - + countries = await _get_available_countries(db_user.promo_group_id) + available_ids = {country['uuid'] for country in countries} + + if country_uuid not in available_ids: + await callback.answer("❌ Эта страна недоступна для вашей промогруппы", show_alert=True) + return + if country_uuid in selected_countries: selected_countries.remove(country_uuid) logger.info(f"🔍 Удалена страна: {country_uuid}") @@ -3808,9 +3829,9 @@ async def handle_add_country_to_subscription( await callback.answer() -async def _should_show_countries_management() -> bool: +async def _should_show_countries_management(promo_group_id: Optional[int] = None) -> bool: try: - countries = await _get_available_countries() + countries = await _get_available_countries(promo_group_id) available_countries = [c for c in countries if c.get('is_available', True)] return len(available_countries) > 1 except Exception as e: @@ -3839,7 +3860,7 @@ async def confirm_add_countries_to_subscription( await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) return - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) total_price = 0 new_countries_names = [] removed_countries_names = [] diff --git a/app/states.py b/app/states.py index 782fae7d..f824f9a5 100644 --- a/app/states.py +++ b/app/states.py @@ -95,6 +95,7 @@ class AdminStates(StatesGroup): editing_server_country = State() editing_server_limit = State() editing_server_description = State() + editing_server_promo_groups = State() creating_server_uuid = State() creating_server_name = State() diff --git a/app/utils/cache.py b/app/utils/cache.py index aeed54f7..63ac2f08 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -179,6 +179,12 @@ async def cached_function(key: str, expire: int = 300): return decorator +async def invalidate_available_countries_cache() -> None: + keys = await cache.get_keys("available_countries*") + for key in keys: + await cache.delete(key) + + class UserCache: @staticmethod From 1bc7a1c143a57959b68a4201f9e458af0624644c Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 17:21:37 +0300 Subject: [PATCH 058/146] Revert "Restrict server access by promo group" --- app/database/crud/promo_group.py | 7 - app/database/crud/server_squad.py | 136 ++--------------- app/database/models.py | 32 +--- app/database/universal_migration.py | 104 ------------- app/handlers/admin/servers.py | 221 ++-------------------------- app/handlers/subscription.py | 105 ++++++------- app/states.py | 1 - app/utils/cache.py | 6 - 8 files changed, 72 insertions(+), 540 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 2e372b84..3bc093f2 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -44,13 +44,6 @@ async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[Pro return await db.get(PromoGroup, group_id) -async def get_all_promo_groups(db: AsyncSession) -> List[PromoGroup]: - result = await db.execute( - select(PromoGroup).order_by(PromoGroup.is_default.desc(), PromoGroup.name) - ) - return result.scalars().all() - - async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]: result = await db.execute( select(PromoGroup).where(PromoGroup.is_default.is_(True)) diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index b351c4ad..eb0692e6 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -4,13 +4,7 @@ from sqlalchemy import select, and_, func, update, delete, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import ( - ServerSquad, - SubscriptionServer, - Subscription, - PromoGroup, - server_squad_promo_groups, -) +from app.database.models import ServerSquad, SubscriptionServer, Subscription logger = logging.getLogger(__name__) @@ -24,43 +18,9 @@ async def create_server_squad( price_kopeks: int = 0, description: str = None, max_users: int = None, - is_available: bool = True, - promo_group_ids: Optional[List[int]] = None, + is_available: bool = True ) -> ServerSquad: - result = await db.execute( - select(PromoGroup.id) - .where(PromoGroup.is_default.is_(True)) - .limit(1) - ) - default_group_id = result.scalar_one_or_none() - - if promo_group_ids is None: - promo_group_ids = [] - if default_group_id is not None: - promo_group_ids.append(default_group_id) - else: - fallback_group_result = await db.execute( - select(PromoGroup.id) - .order_by(PromoGroup.is_default.desc(), PromoGroup.id) - .limit(1) - ) - fallback_group_id = fallback_group_result.scalar_one_or_none() - if fallback_group_id is not None: - promo_group_ids.append(fallback_group_id) - - unique_group_ids = list(dict.fromkeys(promo_group_ids or [])) - - if not unique_group_ids: - raise ValueError("Server squad must have at least one promo group") - - groups_result = await db.execute( - select(PromoGroup).where(PromoGroup.id.in_(unique_group_ids)) - ) - groups = groups_result.scalars().all() - - if len(groups) != len(unique_group_ids): - raise ValueError("One or more promo groups not found") - + server_squad = ServerSquad( squad_uuid=squad_uuid, display_name=display_name, @@ -71,21 +31,12 @@ async def create_server_squad( max_users=max_users, is_available=is_available ) - + db.add(server_squad) - await db.flush() - - server_squad.promo_groups = groups - await db.commit() await db.refresh(server_squad) - - logger.info( - "✅ Создан сервер %s (UUID: %s) с промогруппами: %s", - display_name, - squad_uuid, - ", ".join(group.name for group in groups), - ) + + logger.info(f"✅ Создан сервер {display_name} (UUID: {squad_uuid})") return server_squad @@ -95,9 +46,7 @@ async def get_server_squad_by_uuid( ) -> Optional[ServerSquad]: result = await db.execute( - select(ServerSquad) - .options(selectinload(ServerSquad.promo_groups)) - .where(ServerSquad.squad_uuid == squad_uuid) + select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid) ) return result.scalar_one_or_none() @@ -108,9 +57,7 @@ async def get_server_squad_by_id( ) -> Optional[ServerSquad]: result = await db.execute( - select(ServerSquad) - .options(selectinload(ServerSquad.promo_groups)) - .where(ServerSquad.id == server_id) + select(ServerSquad).where(ServerSquad.id == server_id) ) return result.scalar_one_or_none() @@ -122,7 +69,7 @@ async def get_all_server_squads( limit: int = 50 ) -> Tuple[List[ServerSquad], int]: - query = select(ServerSquad).options(selectinload(ServerSquad.promo_groups)) + query = select(ServerSquad) if available_only: query = query.where(ServerSquad.is_available == True) @@ -144,75 +91,16 @@ async def get_all_server_squads( return servers, total_count -async def get_available_server_squads( - db: AsyncSession, - promo_group_id: Optional[int] = None, -) -> List[ServerSquad]: +async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]: - query = ( + result = await db.execute( select(ServerSquad) - .options(selectinload(ServerSquad.promo_groups)) .where(ServerSquad.is_available == True) + .order_by(ServerSquad.sort_order, ServerSquad.display_name) ) - - if promo_group_id is not None: - query = ( - query.join( - server_squad_promo_groups, - server_squad_promo_groups.c.server_squad_id == ServerSquad.id, - ) - .where(server_squad_promo_groups.c.promo_group_id == promo_group_id) - .distinct() - ) - - query = query.order_by(ServerSquad.sort_order, ServerSquad.display_name) - - result = await db.execute(query) return result.scalars().all() -async def set_server_squad_promo_groups( - db: AsyncSession, - server_id: int, - promo_group_ids: List[int], -) -> Optional[ServerSquad]: - - unique_group_ids = list(dict.fromkeys(promo_group_ids or [])) - - if not unique_group_ids: - logger.warning("Попытка оставить сервер без промогрупп (id=%s)", server_id) - return None - - server = await get_server_squad_by_id(db, server_id) - if not server: - logger.warning("Сервер %s не найден при обновлении промогрупп", server_id) - return None - - groups_result = await db.execute( - select(PromoGroup).where(PromoGroup.id.in_(unique_group_ids)) - ) - groups = groups_result.scalars().all() - - if len(groups) != len(unique_group_ids): - logger.warning( - "Не все промогруппы найдены для сервера %s: %s", - server_id, - unique_group_ids, - ) - return None - - server.promo_groups = groups - await db.commit() - await db.refresh(server) - - logger.info( - "✅ Обновлены промогруппы сервера %s: %s", - server.display_name, - ", ".join(group.name for group in groups), - ) - return server - - async def update_server_squad( db: AsyncSession, server_id: int, diff --git a/app/database/models.py b/app/database/models.py index f0fe6671..91a7a360 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -15,7 +15,6 @@ from sqlalchemy import ( BigInteger, UniqueConstraint, Index, - Table, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -25,22 +24,6 @@ from sqlalchemy.sql import func Base = declarative_base() -server_squad_promo_groups = Table( - "server_squad_promo_groups", - Base.metadata, - Column( - "server_squad_id", - ForeignKey("server_squads.id", ondelete="CASCADE"), - primary_key=True, - ), - Column( - "promo_group_id", - ForeignKey("promo_groups.id", ondelete="CASCADE"), - primary_key=True, - ), -) - - class UserStatus(Enum): ACTIVE = "active" BLOCKED = "blocked" @@ -295,11 +278,6 @@ class PromoGroup(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) users = relationship("User", back_populates="promo_group") - server_squads = relationship( - "ServerSquad", - secondary="server_squad_promo_groups", - back_populates="promo_groups", - ) def _get_period_discounts_map(self) -> Dict[int, int]: raw_discounts = self.period_discounts or {} @@ -854,16 +832,10 @@ class ServerSquad(Base): sort_order = Column(Integer, default=0) max_users = Column(Integer, nullable=True) - current_users = Column(Integer, default=0) - + current_users = Column(Integer, default=0) + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - promo_groups = relationship( - "PromoGroup", - secondary="server_squad_promo_groups", - back_populates="server_squads", - ) @property def price_rubles(self) -> float: diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 215675d5..522747f0 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -608,96 +608,6 @@ async def create_discount_offers_table(): logger.error(f"Ошибка создания таблицы discount_offers: {e}") return False - -async def create_server_squad_promo_groups_table(): - table_exists = await check_table_exists('server_squad_promo_groups') - if table_exists: - logger.info("Таблица server_squad_promo_groups уже существует") - return True - - try: - async with engine.begin() as conn: - db_type = await get_database_type() - - if db_type == 'sqlite': - await conn.execute(text(""" - CREATE TABLE server_squad_promo_groups ( - server_squad_id INTEGER NOT NULL, - promo_group_id INTEGER NOT NULL, - PRIMARY KEY (server_squad_id, promo_group_id), - FOREIGN KEY(server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, - FOREIGN KEY(promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE - ) - """)) - - elif db_type == 'postgresql': - await conn.execute(text(""" - CREATE TABLE IF NOT EXISTS server_squad_promo_groups ( - server_squad_id INTEGER NOT NULL REFERENCES server_squads(id) ON DELETE CASCADE, - promo_group_id INTEGER NOT NULL REFERENCES promo_groups(id) ON DELETE CASCADE, - PRIMARY KEY (server_squad_id, promo_group_id) - ) - """)) - - elif db_type == 'mysql': - await conn.execute(text(""" - CREATE TABLE IF NOT EXISTS server_squad_promo_groups ( - server_squad_id INTEGER NOT NULL, - promo_group_id INTEGER NOT NULL, - PRIMARY KEY (server_squad_id, promo_group_id), - CONSTRAINT fk_sspg_server FOREIGN KEY(server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, - CONSTRAINT fk_sspg_group FOREIGN KEY(promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE - ) - """)) - - else: - raise ValueError(f"Unsupported database type: {db_type}") - - logger.info("✅ Таблица server_squad_promo_groups успешно создана") - return True - - except Exception as e: - logger.error(f"Ошибка создания таблицы server_squad_promo_groups: {e}") - return False - - -async def ensure_server_squads_have_default_promo_group(): - try: - async with engine.begin() as conn: - db_type = await get_database_type() - - default_group_sql = "SELECT id FROM promo_groups WHERE is_default IS TRUE LIMIT 1" - if db_type in {'sqlite', 'mysql'}: - default_group_sql = "SELECT id FROM promo_groups WHERE is_default = 1 LIMIT 1" - - result = await conn.execute(text(default_group_sql)) - row = result.fetchone() - - if not row: - logger.warning("⚠️ Базовая промогруппа не найдена, пропускаем привязку серверов") - return False - - default_group_id = row[0] - - await conn.execute( - text(""" - INSERT INTO server_squad_promo_groups (server_squad_id, promo_group_id) - SELECT ss.id, :group_id - FROM server_squads ss - LEFT JOIN server_squad_promo_groups spg - ON spg.server_squad_id = ss.id - WHERE spg.server_squad_id IS NULL - """), - {"group_id": default_group_id}, - ) - - logger.info("✅ Все серверы без привязки получили базовую промогруппу") - return True - - except Exception as e: - logger.error(f"Ошибка назначения базовой промогруппы серверам: {e}") - return False - async def create_user_messages_table(): table_exists = await check_table_exists('user_messages') if table_exists: @@ -1652,13 +1562,6 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей discount_offers") - logger.info("=== СОЗДАНИЕ СВЯЗИ SERVER_SQUAD_PROMO_GROUPS ===") - promo_link_created = await create_server_squad_promo_groups_table() - if promo_link_created: - logger.info("✅ Таблица server_squad_promo_groups готова") - else: - logger.warning("⚠️ Проблемы с таблицей server_squad_promo_groups") - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===") user_messages_created = await create_user_messages_table() if user_messages_created: @@ -1666,13 +1569,6 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей user_messages") - logger.info("=== НАЗНАЧЕНИЕ БАЗОВОЙ ПРОМОГРУППЫ СЕРВЕРАМ ===") - default_assignment_done = await ensure_server_squads_have_default_promo_group() - if default_assignment_done: - logger.info("✅ Базовая промогруппа назначена серверам без связей") - else: - logger.warning("⚠️ Не удалось назначить базовую промогруппу серверам") - logger.info("=== СОЗДАНИЕ/ОБНОВЛЕНИЕ ТАБЛИЦЫ WELCOME_TEXTS ===") welcome_texts_created = await create_welcome_texts_table() if welcome_texts_created: diff --git a/app/handlers/admin/servers.py b/app/handlers/admin/servers.py index 58c394e3..f5ae0023 100644 --- a/app/handlers/admin/servers.py +++ b/app/handlers/admin/servers.py @@ -1,6 +1,4 @@ import logging -from typing import List, Set - from aiogram import Dispatcher, types, F from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -8,61 +6,17 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.states import AdminStates from app.database.models import User from app.database.crud.server_squad import ( - get_all_server_squads, - get_server_squad_by_id, - update_server_squad, - delete_server_squad, - sync_with_remnawave, - get_server_statistics, - create_server_squad, - get_available_server_squads, - set_server_squad_promo_groups, + get_all_server_squads, get_server_squad_by_id, update_server_squad, + delete_server_squad, sync_with_remnawave, get_server_statistics, + create_server_squad, get_available_server_squads ) -from app.database.crud.promo_group import get_all_promo_groups from app.services.remnawave_service import RemnaWaveService from app.utils.decorators import admin_required, error_handler -from app.utils.cache import invalidate_available_countries_cache +from app.utils.cache import cache logger = logging.getLogger(__name__) -def _format_promo_group_list(promo_groups: List, selected_ids: Set[int]) -> str: - names = [group.name for group in promo_groups if group.id in selected_ids] - return ", ".join(names) if names else "Не выбраны" - - -def _build_server_promo_groups_keyboard( - server_id: int, - promo_groups: List, - selected_ids: Set[int], -): - rows = [] - - for group in promo_groups: - emoji = "✅" if group.id in selected_ids else "⚪" - rows.append([ - types.InlineKeyboardButton( - text=f"{emoji} {group.name}", - callback_data=f"admin_server_group_toggle_{server_id}_{group.id}", - ) - ]) - - rows.append([ - types.InlineKeyboardButton( - text="💾 Сохранить", - callback_data=f"admin_server_group_save_{server_id}", - ) - ]) - rows.append([ - types.InlineKeyboardButton( - text="⬅️ Назад", - callback_data=f"admin_server_edit_{server_id}", - ) - ]) - - return types.InlineKeyboardMarkup(inline_keyboard=rows) - - @admin_required @error_handler async def show_servers_menu( @@ -212,7 +166,7 @@ async def sync_servers_with_remnawave( created, updated, disabled = await sync_with_remnawave(db, squads) - await invalidate_available_countries_cache() + await cache.delete("available_countries") text = f""" ✅ Синхронизация завершена @@ -269,7 +223,6 @@ async def show_server_edit_menu( status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" - promo_group_names = ", ".join(group.name for group in server.promo_groups) if server.promo_groups else "Не назначены" text = f""" 🌐 Редактирование сервера @@ -286,14 +239,13 @@ async def show_server_edit_menu( • Код страны: {server.country_code or 'Не указан'} • Лимит пользователей: {server.max_users or 'Без лимита'} • Текущих пользователей: {server.current_users} -• Промогруппы: {promo_group_names} Описание: {server.description or 'Не указано'} Выберите что изменить: """ - + keyboard = [ [ types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"), @@ -306,9 +258,6 @@ async def show_server_edit_menu( [ types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}") ], - [ - types.InlineKeyboardButton(text="🎯 Промогруппы", callback_data=f"admin_server_edit_groups_{server.id}") - ], [ types.InlineKeyboardButton( text="❌ Отключить" if server.is_available else "✅ Включить", @@ -347,7 +296,7 @@ async def toggle_server_availability( new_status = not server.is_available await update_server_squad(db, server_id, is_available=new_status) - await invalidate_available_countries_cache() + await cache.delete("available_countries") status_text = "включен" if new_status else "отключен" await callback.answer(f"✅ Сервер {status_text}!") @@ -372,7 +321,6 @@ async def toggle_server_availability( • Код страны: {server.country_code or 'Не указан'} • Лимит пользователей: {server.max_users or 'Без лимита'} • Текущих пользователей: {server.current_users} -• Промогруппы: {promo_group_names} Описание: {server.description or 'Не указано'} @@ -392,9 +340,6 @@ async def toggle_server_availability( [ types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}") ], - [ - types.InlineKeyboardButton(text="🎯 Промогруппы", callback_data=f"admin_server_edit_groups_{server.id}") - ], [ types.InlineKeyboardButton( text="❌ Отключить" if server.is_available else "✅ Включить", @@ -477,7 +422,7 @@ async def process_server_price_edit( if server: await state.clear() - await invalidate_available_countries_cache() + await cache.delete("available_countries") price_text = f"{int(price_rubles)} ₽" if price_kopeks > 0 else "Бесплатно" await message.answer( @@ -552,7 +497,7 @@ async def process_server_name_edit( if server: await state.clear() - await invalidate_available_countries_cache() + await cache.delete("available_countries") await message.answer( f"✅ Название сервера изменено на: {new_name}", @@ -625,7 +570,7 @@ async def delete_server_execute( success = await delete_server_squad(db, server_id) if success: - await invalidate_available_countries_cache() + await cache.delete("available_countries") await callback.message.edit_text( f"✅ Сервер {server.display_name} успешно удален!", @@ -756,7 +701,7 @@ async def process_server_country_edit( if server: await state.clear() - await invalidate_available_countries_cache() + await cache.delete("available_countries") country_text = new_country or "Удален" await message.answer( @@ -917,137 +862,6 @@ async def process_server_description_edit( else: await message.answer("❌ Ошибка при обновлении сервера") - -@admin_required -@error_handler -async def start_server_edit_promo_groups( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession, -): - - server_id = int(callback.data.split('_')[-1]) - server = await get_server_squad_by_id(db, server_id) - - if not server: - await callback.answer("❌ Сервер не найден!", show_alert=True) - return - - promo_groups = await get_all_promo_groups(db) - if not promo_groups: - await callback.answer("⚠️ Нет доступных промогрупп", show_alert=True) - return - - selected_ids: Set[int] = {group.id for group in server.promo_groups} - - await state.set_data({ - 'server_id': server_id, - 'server_name': server.display_name, - 'selected_promo_groups': list(selected_ids), - }) - await state.set_state(AdminStates.editing_server_promo_groups) - - selected_text = _format_promo_group_list(promo_groups, selected_ids) - text = ( - f"🎯 Промогруппы сервера\n\n" - f"Сервер: {server.display_name}\n" - f"Текущие промогруппы: {selected_text}\n\n" - "Выберите промогруппы, которым будет доступен сервер." - ) - - await callback.message.edit_text( - text, - reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def toggle_server_promo_group( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession, -): - - parts = callback.data.split('_') - server_id = int(parts[-2]) - group_id = int(parts[-1]) - - data = await state.get_data() - selected_ids: Set[int] = set(data.get('selected_promo_groups', [])) - - if group_id in selected_ids: - if len(selected_ids) == 1: - await callback.answer("⚠️ Должна быть выбрана хотя бы одна промогруппа", show_alert=True) - return - selected_ids.remove(group_id) - else: - selected_ids.add(group_id) - - data['selected_promo_groups'] = list(selected_ids) - await state.set_data(data) - - promo_groups = await get_all_promo_groups(db) - selected_text = _format_promo_group_list(promo_groups, selected_ids) - - await callback.message.edit_text( - f"🎯 Промогруппы сервера\n\n" - f"Сервер: {data.get('server_name', 'Неизвестно')}\n" - f"Текущие промогруппы: {selected_text}\n\n" - "Выберите промогруппы, которым будет доступен сервер.", - reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids), - parse_mode="HTML", - ) - - await callback.answer() - - -@admin_required -@error_handler -async def save_server_promo_groups( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession, -): - - data = await state.get_data() - server_id_value = data.get('server_id') - if server_id_value is None: - await callback.answer("❌ Не удалось определить сервер", show_alert=True) - return - - server_id = int(server_id_value) - selected_ids: List[int] = data.get('selected_promo_groups', []) - - if not selected_ids: - await callback.answer("⚠️ Выберите хотя бы одну промогруппу", show_alert=True) - return - - server = await set_server_squad_promo_groups(db, server_id, selected_ids) - - if not server: - await callback.answer("❌ Не удалось сохранить промогруппы", show_alert=True) - return - - await state.clear() - await invalidate_available_countries_cache() - - promo_group_names = ", ".join(group.name for group in server.promo_groups) if server.promo_groups else "Не назначены" - - await callback.message.edit_text( - f"✅ Промогруппы сервера обновлены:\n{promo_group_names}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")] - ]), - parse_mode="HTML", - ) - await callback.answer() - @admin_required @error_handler async def sync_server_user_counts_handler( @@ -1126,16 +940,13 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_")) dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_")) dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_")) - dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_")) - dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_")) - dp.callback_query.register(start_server_edit_promo_groups, F.data.startswith("admin_server_edit_groups_")) - dp.callback_query.register(toggle_server_promo_group, F.data.startswith("admin_server_group_toggle_")) - dp.callback_query.register(save_server_promo_groups, F.data.startswith("admin_server_group_save_")) - + dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_")) + dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_")) + dp.message.register(process_server_name_edit, AdminStates.editing_server_name) dp.message.register(process_server_price_edit, AdminStates.editing_server_price) - dp.message.register(process_server_country_edit, AdminStates.editing_server_country) - dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit) + dp.message.register(process_server_country_edit, AdminStates.editing_server_country) + dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit) dp.message.register(process_server_description_edit, AdminStates.editing_server_description) dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm")) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 1616e9ba..3eeee497 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -99,7 +99,7 @@ async def _prepare_subscription_summary( ) summary_data = dict(data) - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() months_in_period = calculate_months_from_days(summary_data['period_days']) period_display = format_period_description(summary_data['period_days'], db_user.language) @@ -1003,7 +1003,7 @@ async def return_to_saved_cart( from app.utils.pricing_utils import calculate_months_from_days, format_period_description - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() selected_countries_names = [] months_in_period = calculate_months_from_days(data['period_days']) @@ -1043,7 +1043,7 @@ async def handle_add_countries( db: AsyncSession, state: FSMContext ): - if not await _should_show_countries_management(db_user.promo_group_id): + if not await _should_show_countries_management(): await callback.answer("ℹ️ Управление серверами недоступно - доступен только один сервер", show_alert=True) return @@ -1054,7 +1054,7 @@ async def handle_add_countries( await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True) return - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() current_countries = subscription.connected_squads current_countries_names = [] @@ -1138,26 +1138,21 @@ async def handle_manage_country( return data = await state.get_data() - countries = await _get_available_countries(db_user.promo_group_id) - available_ids = {country['uuid'] for country in countries} - - if country_uuid not in available_ids: - await callback.answer("❌ Эта страна недоступна для вашей промогруппы", show_alert=True) - return - current_selected = data.get('countries', subscription.connected_squads.copy()) - + if country_uuid in current_selected: current_selected.remove(country_uuid) action = "removed" else: current_selected.append(country_uuid) action = "added" - + logger.info(f"🔍 Страна {country_uuid} {action}") - + await state.update_data(countries=current_selected) + countries = await _get_available_countries() + try: await callback.message.edit_reply_markup( reply_markup=get_manage_countries_keyboard( @@ -1208,7 +1203,7 @@ async def apply_countries_changes( logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() months_to_pay = get_remaining_months(subscription.end_date) @@ -2532,15 +2527,15 @@ async def select_period( ) await state.set_state(SubscriptionStates.selecting_traffic) else: - if await _should_show_countries_management(db_user.promo_group_id): - countries = await _get_available_countries(db_user.promo_group_id) + if await _should_show_countries_management(): + countries = await _get_available_countries() await callback.message.edit_text( texts.SELECT_COUNTRIES, reply_markup=get_countries_keyboard(countries, [], db_user.language) ) await state.set_state(SubscriptionStates.selecting_countries) else: - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) @@ -2608,7 +2603,7 @@ async def get_traffic_packages_info() -> str: async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession): devices_used = await get_current_devices_count(db_user) - countries_info = await _get_countries_info(subscription.connected_squads, db_user.promo_group_id) + countries_info = await _get_countries_info(subscription.connected_squads) countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет" subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..." @@ -2688,15 +2683,15 @@ async def select_traffic( await state.set_data(data) - if await _should_show_countries_management(db_user.promo_group_id): - countries = await _get_available_countries(db_user.promo_group_id) + if await _should_show_countries_management(): + countries = await _get_available_countries() await callback.message.edit_text( texts.SELECT_COUNTRIES, reply_markup=get_countries_keyboard(countries, [], db_user.language) ) await state.set_state(SubscriptionStates.selecting_countries) else: - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) @@ -2722,19 +2717,13 @@ async def select_country( data = await state.get_data() selected_countries = data.get('countries', []) - - countries = await _get_available_countries(db_user.promo_group_id) - available_ids = {country['uuid'] for country in countries} - - if country_uuid not in available_ids: - await callback.answer("❌ Эта страна недоступна для вашей промогруппы", show_alert=True) - return - if country_uuid in selected_countries: selected_countries.remove(country_uuid) else: selected_countries.append(country_uuid) + countries = await _get_available_countries() + period_base_price = PERIOD_PRICES[data['period_days']] from app.utils.pricing_utils import apply_percentage_discount @@ -2808,7 +2797,7 @@ async def select_devices( settings.get_traffic_price(data['traffic_gb']) ) - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() countries_price = sum( c['price_kopeks'] for c in countries if c['uuid'] in data['countries'] @@ -2877,7 +2866,7 @@ async def confirm_purchase( else None ) - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() months_in_period = data.get( 'months_in_period', calculate_months_from_days(data['period_days']) @@ -3534,7 +3523,7 @@ async def handle_subscription_settings( Выберите что хотите изменить: """ - show_countries = await _should_show_countries_management(db_user.promo_group_id) + show_countries = await _should_show_countries_management() await callback.message.edit_text( settings_text, @@ -3647,8 +3636,8 @@ async def handle_subscription_config_back( await state.set_state(SubscriptionStates.selecting_period) elif current_state == SubscriptionStates.selecting_devices.state: - if await _should_show_countries_management(db_user.promo_group_id): - countries = await _get_available_countries(db_user.promo_group_id) + if await _should_show_countries_management(): + countries = await _get_available_countries() data = await state.get_data() selected_countries = data.get('countries', []) @@ -3694,24 +3683,19 @@ async def handle_subscription_cancel( await callback.answer("❌ Покупка отменена") -async def _get_available_countries(promo_group_id: Optional[int] = None): - from app.utils.cache import cache, cache_key +async def _get_available_countries(): + from app.utils.cache import cache from app.database.database import AsyncSessionLocal from app.database.crud.server_squad import get_available_server_squads - - cache_key_name = cache_key("available_countries", promo_group_id or "all") - - cached_countries = await cache.get(cache_key_name) + + cached_countries = await cache.get("available_countries") if cached_countries: return cached_countries - + try: async with AsyncSessionLocal() as db: - available_servers = await get_available_server_squads( - db, - promo_group_id=promo_group_id, - ) - + available_servers = await get_available_server_squads(db) + countries = [] for server in available_servers: countries.append({ @@ -3750,20 +3734,20 @@ async def _get_available_countries(promo_group_id: Optional[int] = None): "is_available": True }) - await cache.set(cache_key_name, countries, 300) + await cache.set("available_countries", countries, 300) return countries - + except Exception as e: logger.error(f"Ошибка получения списка стран: {e}") fallback_countries = [ {"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True}, ] - - await cache.set(cache_key_name, fallback_countries, 60) + + await cache.set("available_countries", fallback_countries, 60) return fallback_countries -async def _get_countries_info(squad_uuids, promo_group_id: Optional[int] = None): - countries = await _get_available_countries(promo_group_id) +async def _get_countries_info(squad_uuids): + countries = await _get_available_countries() return [c for c in countries if c['uuid'] in squad_uuids] async def handle_reset_devices( @@ -3792,13 +3776,8 @@ async def handle_add_country_to_subscription( logger.info(f"🔍 Данные состояния: {data}") selected_countries = data.get('countries', []) - countries = await _get_available_countries(db_user.promo_group_id) - available_ids = {country['uuid'] for country in countries} - - if country_uuid not in available_ids: - await callback.answer("❌ Эта страна недоступна для вашей промогруппы", show_alert=True) - return - + countries = await _get_available_countries() + if country_uuid in selected_countries: selected_countries.remove(country_uuid) logger.info(f"🔍 Удалена страна: {country_uuid}") @@ -3829,9 +3808,9 @@ async def handle_add_country_to_subscription( await callback.answer() -async def _should_show_countries_management(promo_group_id: Optional[int] = None) -> bool: +async def _should_show_countries_management() -> bool: try: - countries = await _get_available_countries(promo_group_id) + countries = await _get_available_countries() available_countries = [c for c in countries if c.get('is_available', True)] return len(available_countries) > 1 except Exception as e: @@ -3860,7 +3839,7 @@ async def confirm_add_countries_to_subscription( await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) return - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() total_price = 0 new_countries_names = [] removed_countries_names = [] diff --git a/app/states.py b/app/states.py index f824f9a5..782fae7d 100644 --- a/app/states.py +++ b/app/states.py @@ -95,7 +95,6 @@ class AdminStates(StatesGroup): editing_server_country = State() editing_server_limit = State() editing_server_description = State() - editing_server_promo_groups = State() creating_server_uuid = State() creating_server_name = State() diff --git a/app/utils/cache.py b/app/utils/cache.py index 63ac2f08..aeed54f7 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -179,12 +179,6 @@ async def cached_function(key: str, expire: int = 300): return decorator -async def invalidate_available_countries_cache() -> None: - keys = await cache.get_keys("available_countries*") - for key in keys: - await cache.delete(key) - - class UserCache: @staticmethod From cfa50739d850f40e9787c9beed8c8d9d40833672 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 17:22:12 +0300 Subject: [PATCH 059/146] Restrict admin country management by promo groups --- app/database/crud/promo_group.py | 9 + app/database/crud/server_squad.py | 122 ++++++- app/database/models.py | 37 +- app/database/universal_migration.py | 103 ++++++ app/handlers/admin/servers.py | 501 +++++++++++++++++++++------- app/handlers/admin/users.py | 287 ++++++++++------ app/handlers/subscription.py | 135 +++++--- app/states.py | 1 + app/utils/cache.py | 11 + 9 files changed, 915 insertions(+), 291 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 3bc093f2..87a8d186 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -40,6 +40,15 @@ async def get_promo_groups_with_counts( return result.all() +async def get_all_promo_groups(db: AsyncSession) -> List[PromoGroup]: + result = await db.execute( + select(PromoGroup).order_by( + PromoGroup.is_default.desc(), PromoGroup.name + ) + ) + return result.scalars().all() + + async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]: return await db.get(PromoGroup, group_id) diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index eb0692e6..a2b5975e 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -4,7 +4,12 @@ from sqlalchemy import select, and_, func, update, delete, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import ServerSquad, SubscriptionServer, Subscription +from app.database.models import ( + ServerSquad, + SubscriptionServer, + Subscription, + PromoGroup, +) logger = logging.getLogger(__name__) @@ -18,9 +23,10 @@ async def create_server_squad( price_kopeks: int = 0, description: str = None, max_users: int = None, - is_available: bool = True + is_available: bool = True, + promo_group_ids: Optional[List[int]] = None, ) -> ServerSquad: - + server_squad = ServerSquad( squad_uuid=squad_uuid, display_name=display_name, @@ -29,13 +35,43 @@ async def create_server_squad( price_kopeks=price_kopeks, description=description, max_users=max_users, - is_available=is_available + is_available=is_available, ) - + db.add(server_squad) + await db.flush() + + target_group_ids = [ + group_id for group_id in (promo_group_ids or []) if isinstance(group_id, int) + ] + + if target_group_ids: + groups_result = await db.execute( + select(PromoGroup).where(PromoGroup.id.in_(target_group_ids)) + ) + groups = groups_result.scalars().all() + else: + groups = [] + + if not groups: + default_group_result = await db.execute( + select(PromoGroup).where(PromoGroup.is_default.is_(True)) + ) + default_group = default_group_result.scalars().first() + if default_group: + groups = [default_group] + else: + logger.warning( + "Не найдена базовая промогруппа для сервера %s. Сервер останется без ограничений", + display_name, + ) + + if groups: + server_squad.promo_groups = groups + await db.commit() await db.refresh(server_squad) - + logger.info(f"✅ Создан сервер {display_name} (UUID: {squad_uuid})") return server_squad @@ -46,7 +82,9 @@ async def get_server_squad_by_uuid( ) -> Optional[ServerSquad]: result = await db.execute( - select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid) + select(ServerSquad) + .options(selectinload(ServerSquad.promo_groups)) + .where(ServerSquad.squad_uuid == squad_uuid) ) return result.scalar_one_or_none() @@ -57,7 +95,9 @@ async def get_server_squad_by_id( ) -> Optional[ServerSquad]: result = await db.execute( - select(ServerSquad).where(ServerSquad.id == server_id) + select(ServerSquad) + .options(selectinload(ServerSquad.promo_groups)) + .where(ServerSquad.id == server_id) ) return result.scalar_one_or_none() @@ -69,7 +109,7 @@ async def get_all_server_squads( limit: int = 50 ) -> Tuple[List[ServerSquad], int]: - query = select(ServerSquad) + query = select(ServerSquad).options(selectinload(ServerSquad.promo_groups)) if available_only: query = query.where(ServerSquad.is_available == True) @@ -84,21 +124,33 @@ async def get_all_server_squads( offset = (page - 1) * limit query = query.order_by(ServerSquad.sort_order, ServerSquad.display_name) query = query.offset(offset).limit(limit) - + result = await db.execute(query) - servers = result.scalars().all() - + servers = result.scalars().unique().all() + return servers, total_count -async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]: - - result = await db.execute( +async def get_available_server_squads( + db: AsyncSession, + *, + promo_group_id: Optional[int] = None, +) -> List[ServerSquad]: + + query = ( select(ServerSquad) - .where(ServerSquad.is_available == True) + .options(selectinload(ServerSquad.promo_groups)) + .where(ServerSquad.is_available.is_(True)) .order_by(ServerSquad.sort_order, ServerSquad.display_name) ) - return result.scalars().all() + + if promo_group_id is not None: + query = query.join(ServerSquad.promo_groups).where( + PromoGroup.id == promo_group_id + ) + + result = await db.execute(query) + return result.scalars().unique().all() async def update_server_squad( @@ -124,10 +176,44 @@ async def update_server_squad( ) await db.commit() - + return await get_server_squad_by_id(db, server_id) +async def set_server_squad_promo_groups( + db: AsyncSession, + server_id: int, + promo_group_ids: List[int], +) -> Optional[ServerSquad]: + + if not promo_group_ids: + return None + + server_result = await db.execute( + select(ServerSquad) + .options(selectinload(ServerSquad.promo_groups)) + .where(ServerSquad.id == server_id) + ) + server = server_result.scalar_one_or_none() + + if not server: + return None + + groups_result = await db.execute( + select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids)) + ) + groups = groups_result.scalars().all() + + if not groups: + return None + + server.promo_groups = groups + await db.commit() + await db.refresh(server) + + return server + + async def delete_server_squad(db: AsyncSession, server_id: int) -> bool: connections_result = await db.execute( diff --git a/app/database/models.py b/app/database/models.py index 91a7a360..fac7e572 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -15,6 +15,7 @@ from sqlalchemy import ( BigInteger, UniqueConstraint, Index, + Table, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -263,6 +264,25 @@ class Pal24Payment(Base): ) +server_squad_promo_groups = Table( + "server_squad_promo_groups", + Base.metadata, + Column( + "server_squad_id", + Integer, + ForeignKey("server_squads.id", ondelete="CASCADE"), + primary_key=True, + ), + Column( + "promo_group_id", + Integer, + ForeignKey("promo_groups.id", ondelete="CASCADE"), + primary_key=True, + ), + Column("created_at", DateTime, server_default=func.now()), +) + + class PromoGroup(Base): __tablename__ = "promo_groups" @@ -278,6 +298,11 @@ class PromoGroup(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) users = relationship("User", back_populates="promo_group") + server_squads = relationship( + "ServerSquad", + secondary="server_squad_promo_groups", + back_populates="promo_groups", + ) def _get_period_discounts_map(self) -> Dict[int, int]: raw_discounts = self.period_discounts or {} @@ -812,7 +837,7 @@ class BroadcastHistory(Base): class ServerSquad(Base): __tablename__ = "server_squads" - + id = Column(Integer, primary_key=True, index=True) squad_uuid = Column(String(255), unique=True, nullable=False, index=True) @@ -832,10 +857,16 @@ class ServerSquad(Base): sort_order = Column(Integer, default=0) max_users = Column(Integer, nullable=True) - current_users = Column(Integer, default=0) - + current_users = Column(Integer, default=0) + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + promo_groups = relationship( + "PromoGroup", + secondary="server_squad_promo_groups", + back_populates="server_squads", + ) @property def price_rubles(self) -> float: diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 522747f0..f7395704 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -861,6 +861,102 @@ async def ensure_promo_groups_setup(): "users", "auto_promo_group_assigned" ) + +async def ensure_server_squad_promo_groups_link() -> bool: + logger.info("=== НАСТРОЙКА server_squad_promo_groups ===") + + try: + table_exists = await check_table_exists("server_squad_promo_groups") + + async with engine.begin() as conn: + db_type = await get_database_type() + + if not table_exists: + if db_type == "sqlite": + create_sql = """ + CREATE TABLE IF NOT EXISTS server_squad_promo_groups ( + server_squad_id INTEGER NOT NULL, + promo_group_id INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (server_squad_id, promo_group_id), + FOREIGN KEY (server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, + FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE + ) + """ + elif db_type == "postgresql": + create_sql = """ + CREATE TABLE IF NOT EXISTS server_squad_promo_groups ( + server_squad_id INTEGER NOT NULL, + promo_group_id INTEGER NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (server_squad_id, promo_group_id), + FOREIGN KEY (server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, + FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE + ) + """ + elif db_type == "mysql": + create_sql = """ + CREATE TABLE IF NOT EXISTS server_squad_promo_groups ( + server_squad_id INT NOT NULL, + promo_group_id INT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (server_squad_id, promo_group_id), + FOREIGN KEY (server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, + FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE + ) ENGINE=InnoDB + """ + else: + logger.error( + f"Неподдерживаемый тип БД для server_squad_promo_groups: {db_type}" + ) + return False + + await conn.execute(text(create_sql)) + logger.info("Создана таблица server_squad_promo_groups") + + if db_type == "postgresql": + default_query = ( + "SELECT id FROM promo_groups WHERE is_default IS TRUE ORDER BY id LIMIT 1" + ) + else: + default_query = ( + "SELECT id FROM promo_groups WHERE is_default = 1 ORDER BY id LIMIT 1" + ) + + result = await conn.execute(text(default_query)) + row = result.fetchone() + + if not row: + logger.warning("Базовая промогруппа не найдена, пропускаем привязку серверов") + return True + + default_group_id = row[0] + + await conn.execute( + text( + """ + INSERT INTO server_squad_promo_groups (server_squad_id, promo_group_id) + SELECT ss.id, :default_id + FROM server_squads ss + WHERE NOT EXISTS ( + SELECT 1 FROM server_squad_promo_groups spg + WHERE spg.server_squad_id = ss.id + ) + """ + ), + {"default_id": default_group_id}, + ) + + logger.info( + "Серверы без назначенных промогрупп привязаны к базовой промогруппе" + ) + + return True + + except Exception as e: + logger.error(f"Ошибка настройки server_squad_promo_groups: {e}") + return False + if not auto_promo_flag_exists: if db_type == "sqlite": await conn.execute( @@ -1670,6 +1766,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с настройкой промо групп") + logger.info("=== ПРИВЯЗКА СЕРВЕРОВ К ПРОМО ГРУППАМ ===") + promo_links_ready = await ensure_server_squad_promo_groups_link() + if promo_links_ready: + logger.info("✅ Серверы привязаны к промогруппам") + else: + logger.warning("⚠️ Проблемы с привязкой серверов к промогруппам") + logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: diff --git a/app/handlers/admin/servers.py b/app/handlers/admin/servers.py index f5ae0023..cd8a3ff4 100644 --- a/app/handlers/admin/servers.py +++ b/app/handlers/admin/servers.py @@ -4,19 +4,194 @@ from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.states import AdminStates -from app.database.models import User +from app.database.models import User, ServerSquad from app.database.crud.server_squad import ( - get_all_server_squads, get_server_squad_by_id, update_server_squad, - delete_server_squad, sync_with_remnawave, get_server_statistics, - create_server_squad, get_available_server_squads + get_all_server_squads, + get_server_squad_by_id, + update_server_squad, + delete_server_squad, + sync_with_remnawave, + get_server_statistics, + create_server_squad, + get_available_server_squads, + set_server_squad_promo_groups, ) +from app.database.crud.promo_group import get_all_promo_groups from app.services.remnawave_service import RemnaWaveService from app.utils.decorators import admin_required, error_handler -from app.utils.cache import cache +from app.utils.cache import cache, invalidate_available_countries_cache logger = logging.getLogger(__name__) +def _format_server_promo_groups(server: ServerSquad) -> str: + if not server.promo_groups: + return "—" + + sorted_groups = sorted( + server.promo_groups, + key=lambda group: ( + not getattr(group, "is_default", False), + group.name.lower(), + ), + ) + + formatted = [] + for group in sorted_groups: + label = group.name + if getattr(group, "is_default", False): + label = f"{label} ⭐" + formatted.append(label) + + return ", ".join(formatted) + + +def _build_server_edit_view(server: ServerSquad): + status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" + price_text = ( + f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" + ) + promo_groups_text = _format_server_promo_groups(server) + + text = f""" +🌐 Редактирование сервера + +Информация: +• ID: {server.id} +• UUID: {server.squad_uuid} +• Название: {server.display_name} +• Оригинальное: {server.original_name or 'Не указано'} +• Статус: {status_emoji} + +Настройки: +• Цена: {price_text} +• Код страны: {server.country_code or 'Не указан'} +• Лимит пользователей: {server.max_users or 'Без лимита'} +• Текущих пользователей: {server.current_users} +• Промогруппы: {promo_groups_text} + +Описание: +{server.description or 'Не указано'} + +Выберите что изменить: +""" + + keyboard = [ + [ + types.InlineKeyboardButton( + text="✏️ Название", + callback_data=f"admin_server_edit_name_{server.id}", + ), + types.InlineKeyboardButton( + text="💰 Цена", + callback_data=f"admin_server_edit_price_{server.id}", + ), + ], + [ + types.InlineKeyboardButton( + text="🌍 Страна", + callback_data=f"admin_server_edit_country_{server.id}", + ), + types.InlineKeyboardButton( + text="👥 Лимит", + callback_data=f"admin_server_edit_limit_{server.id}", + ), + ], + [ + types.InlineKeyboardButton( + text="🎯 Промогруппы", + callback_data=f"admin_server_edit_promos_{server.id}", + ) + ], + [ + types.InlineKeyboardButton( + text="📝 Описание", + callback_data=f"admin_server_edit_desc_{server.id}", + ) + ], + [ + types.InlineKeyboardButton( + text="❌ Отключить" if server.is_available else "✅ Включить", + callback_data=f"admin_server_toggle_{server.id}", + ) + ], + [ + types.InlineKeyboardButton( + text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}" + ), + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_servers_list" + ), + ], + ] + + return text, types.InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def _build_server_promo_groups_keyboard( + server_id: int, + promo_groups, + selected_ids: set, +): + buttons = [] + + for group in promo_groups: + is_selected = group.id in selected_ids + emoji = "✅" if is_selected else "⚪" + label = group.name + if getattr(group, "is_default", False): + label = f"{label} ⭐" + + buttons.append( + [ + types.InlineKeyboardButton( + text=f"{emoji} {label}", + callback_data=f"admin_server_promos_toggle_{server_id}_{group.id}", + ) + ] + ) + + buttons.append( + [ + types.InlineKeyboardButton( + text="💾 Сохранить", + callback_data=f"admin_server_promos_save_{server_id}", + ) + ] + ) + buttons.append( + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data=f"admin_server_edit_{server_id}" + ) + ] + ) + + return types.InlineKeyboardMarkup(inline_keyboard=buttons) + + +def _build_server_promo_groups_text( + server: ServerSquad, + promo_groups, + selected_ids: set, +) -> str: + selected_names = [ + group.name + for group in promo_groups + if group.id in selected_ids + ] + + selected_display = ", ".join(selected_names) if selected_names else "не выбраны" + + return ( + "🎯 Промогруппы сервера\n\n" + f"Сервер: {server.display_name}\n" + "Выберите промогруппы, которым будет доступен этот сервер.\n\n" + f"Сейчас выбрано: {selected_display}\n\n" + "⚠️ Должна быть активна минимум одна промогруппа." + ) + + @admin_required @error_handler async def show_servers_menu( @@ -165,8 +340,8 @@ async def sync_servers_with_remnawave( return created, updated, disabled = await sync_with_remnawave(db, squads) - - await cache.delete("available_countries") + + await invalidate_available_countries_cache() text = f""" ✅ Синхронизация завершена @@ -210,70 +385,26 @@ async def sync_servers_with_remnawave( @error_handler async def show_server_edit_menu( callback: types.CallbackQuery, + state: FSMContext, db_user: User, - db: AsyncSession + db: AsyncSession, ): - + server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) - + if not server: await callback.answer("❌ Сервер не найден!", show_alert=True) return - - status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" - price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" - - text = f""" -🌐 Редактирование сервера -Информация: -• ID: {server.id} -• UUID: {server.squad_uuid} -• Название: {server.display_name} -• Оригинальное: {server.original_name or 'Не указано'} -• Статус: {status_emoji} + await state.clear() -Настройки: -• Цена: {price_text} -• Код страны: {server.country_code or 'Не указан'} -• Лимит пользователей: {server.max_users or 'Без лимита'} -• Текущих пользователей: {server.current_users} + text, markup = _build_server_edit_view(server) -Описание: -{server.description or 'Не указано'} - -Выберите что изменить: -""" - - keyboard = [ - [ - types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"), - types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}") - ], - [ - types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"), - types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}") - ], - [ - types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}") - ], - [ - types.InlineKeyboardButton( - text="❌ Отключить" if server.is_available else "✅ Включить", - callback_data=f"admin_server_toggle_{server.id}" - ) - ], - [ - types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list") - ] - ] - await callback.message.edit_text( text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + reply_markup=markup, + parse_mode="HTML", ) await callback.answer() @@ -295,70 +426,172 @@ async def toggle_server_availability( new_status = not server.is_available await update_server_squad(db, server_id, is_available=new_status) - - await cache.delete("available_countries") + + await invalidate_available_countries_cache() status_text = "включен" if new_status else "отключен" await callback.answer(f"✅ Сервер {status_text}!") - + server = await get_server_squad_by_id(db, server_id) - - status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" - price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" - - text = f""" -🌐 Редактирование сервера -Информация: -• ID: {server.id} -• UUID: {server.squad_uuid} -• Название: {server.display_name} -• Оригинальное: {server.original_name or 'Не указано'} -• Статус: {status_emoji} + text, markup = _build_server_edit_view(server) -Настройки: -• Цена: {price_text} -• Код страны: {server.country_code or 'Не указан'} -• Лимит пользователей: {server.max_users or 'Без лимита'} -• Текущих пользователей: {server.current_users} - -Описание: -{server.description or 'Не указано'} - -Выберите что изменить: -""" - - keyboard = [ - [ - types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"), - types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}") - ], - [ - types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"), - types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}") - ], - [ - types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}") - ], - [ - types.InlineKeyboardButton( - text="❌ Отключить" if server.is_available else "✅ Включить", - callback_data=f"admin_server_toggle_{server.id}" - ) - ], - [ - types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list") - ] - ] - await callback.message.edit_text( text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + reply_markup=markup, + parse_mode="HTML", ) +@admin_required +@error_handler +async def start_edit_server_promo_groups( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession, +): + + server_id = int(callback.data.split('_')[-1]) + server = await get_server_squad_by_id(db, server_id) + + if not server: + await callback.answer("❌ Сервер не найден!", show_alert=True) + return + + promo_groups = await get_all_promo_groups(db) + + if not promo_groups: + await callback.answer("⚠️ Нет доступных промогрупп", show_alert=True) + return + + selected_ids = { + group.id for group in server.promo_groups if group.id is not None + } + + if not selected_ids: + selected_ids.add(promo_groups[0].id) + + await state.set_state(AdminStates.editing_server_promo_groups) + await state.set_data( + { + "server_id": server_id, + "promo_groups": list(selected_ids), + } + ) + + text = _build_server_promo_groups_text(server, promo_groups, selected_ids) + keyboard = _build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids) + + await callback.message.edit_text( + text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_server_promo_group( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession, +): + + parts = callback.data.split('_') + + try: + server_id = int(parts[-2]) + group_id = int(parts[-1]) + except (ValueError, IndexError): + await callback.answer("❌ Некорректные данные", show_alert=True) + return + + data = await state.get_data() + + if data.get("server_id") != server_id: + data["server_id"] = server_id + + selected_ids = set(int(i) for i in data.get("promo_groups", [])) + + if group_id in selected_ids: + if len(selected_ids) == 1: + await callback.answer( + "⚠️ Должна быть выбрана минимум одна промогруппа", + show_alert=True, + ) + return + selected_ids.remove(group_id) + else: + selected_ids.add(group_id) + + await state.update_data( + { + "server_id": server_id, + "promo_groups": list(selected_ids), + } + ) + + server = await get_server_squad_by_id(db, server_id) + promo_groups = await get_all_promo_groups(db) + + if not server or not promo_groups: + await callback.answer("❌ Не удалось обновить данные", show_alert=True) + return + + text = _build_server_promo_groups_text(server, promo_groups, selected_ids) + keyboard = _build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids) + + await callback.message.edit_text( + text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def save_server_promo_groups( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession, +): + + data = await state.get_data() + server_id = int(data.get("server_id", 0)) + promo_group_ids = [int(i) for i in data.get("promo_groups", []) if i] + + if not server_id: + await callback.answer("❌ Сервер не найден", show_alert=True) + return + + if not promo_group_ids: + await callback.answer("⚠️ Выберите хотя бы одну промогруппу", show_alert=True) + return + + server = await set_server_squad_promo_groups(db, server_id, promo_group_ids) + + if not server: + await callback.answer("❌ Не удалось обновить промогруппы", show_alert=True) + return + + await state.clear() + await invalidate_available_countries_cache() + + text, markup = _build_server_edit_view(server) + + await callback.message.edit_text( + text, + reply_markup=markup, + parse_mode="HTML", + ) + await callback.answer("✅ Промогруппы обновлены") + + @admin_required @error_handler async def start_server_edit_price( @@ -418,11 +651,11 @@ async def process_server_price_edit( price_kopeks = int(price_rubles * 100) server = await update_server_squad(db, server_id, price_kopeks=price_kopeks) - + if server: await state.clear() - - await cache.delete("available_countries") + + await invalidate_available_countries_cache() price_text = f"{int(price_rubles)} ₽" if price_kopeks > 0 else "Бесплатно" await message.answer( @@ -493,11 +726,11 @@ async def process_server_name_edit( return server = await update_server_squad(db, server_id, display_name=new_name) - + if server: await state.clear() - - await cache.delete("available_countries") + + await invalidate_available_countries_cache() await message.answer( f"✅ Название сервера изменено на: {new_name}", @@ -568,9 +801,9 @@ async def delete_server_execute( return success = await delete_server_squad(db, server_id) - + if success: - await cache.delete("available_countries") + await invalidate_available_countries_cache() await callback.message.edit_text( f"✅ Сервер {server.display_name} успешно удален!", @@ -697,11 +930,11 @@ async def process_server_country_edit( return server = await update_server_squad(db, server_id, country_code=new_country) - + if server: await state.clear() - - await cache.delete("available_countries") + + await invalidate_available_countries_cache() country_text = new_country or "Удален" await message.answer( @@ -934,8 +1167,28 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(sync_server_user_counts_handler, F.data == "admin_servers_sync_counts") dp.callback_query.register(show_server_detailed_stats, F.data == "admin_servers_stats") - dp.callback_query.register(show_server_edit_menu, F.data.startswith("admin_server_edit_") & ~F.data.contains("name") & ~F.data.contains("price") & ~F.data.contains("country") & ~F.data.contains("limit") & ~F.data.contains("desc")) + dp.callback_query.register( + show_server_edit_menu, + F.data.startswith("admin_server_edit_") + & ~F.data.contains("name") + & ~F.data.contains("price") + & ~F.data.contains("country") + & ~F.data.contains("limit") + & ~F.data.contains("desc") + & ~F.data.contains("promos"), + ) dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_")) + dp.callback_query.register(start_edit_server_promo_groups, F.data.startswith("admin_server_edit_promos_")) + dp.callback_query.register( + toggle_server_promo_group, + F.data.startswith("admin_server_promos_toggle_"), + state=AdminStates.editing_server_promo_groups, + ) + dp.callback_query.register( + save_server_promo_groups, + F.data.startswith("admin_server_promos_save_"), + state=AdminStates.editing_server_promo_groups, + ) dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_")) dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_")) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 45fe983f..9e066135 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -1,5 +1,7 @@ import logging from datetime import datetime, timedelta +from typing import List, Optional, Sequence, Set, Tuple + from aiogram import Dispatcher, types, F from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from aiogram.fsm.context import FSMContext @@ -7,7 +9,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import AdminStates -from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType +from app.database.models import ( + User, + UserStatus, + Subscription, + SubscriptionStatus, + TransactionType, + ServerSquad, +) from app.database.crud.user import get_user_by_id from app.database.crud.campaign import ( get_campaign_registration_by_user, @@ -25,7 +34,11 @@ from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime, format_time_ago from app.services.remnawave_service import RemnaWaveService from app.external.remnawave_api import TrafficLimitStrategy -from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_uuid, get_server_squad_by_id +from app.database.crud.server_squad import ( + get_all_server_squads, + get_server_squad_by_uuid, + get_server_squad_by_id, +) logger = logging.getLogger(__name__) @@ -1871,6 +1884,122 @@ async def show_server_selection( await _show_servers_for_user(callback, user_id, db) await callback.answer() +def _is_server_allowed_for_user(server: ServerSquad, user: Optional[User]) -> bool: + promo_groups = list(getattr(server, "promo_groups", []) or []) + + if not promo_groups: + return True + + user_group_id = getattr(user, "promo_group_id", None) + + if user_group_id is None: + return any(getattr(group, "is_default", False) for group in promo_groups) + + return any(group.id == user_group_id for group in promo_groups) + + +def _prepare_server_choices_for_user( + user: Optional[User], + servers: Sequence[ServerSquad], + current_squads: Set[str], +) -> List[Tuple[ServerSquad, bool, bool]]: + selected_restricted: List[Tuple[ServerSquad, bool, bool]] = [] + selected_allowed: List[Tuple[ServerSquad, bool, bool]] = [] + available_allowed: List[Tuple[ServerSquad, bool, bool]] = [] + inactive_allowed: List[Tuple[ServerSquad, bool, bool]] = [] + + for server in servers: + is_selected = server.squad_uuid in current_squads + is_allowed = _is_server_allowed_for_user(server, user) + + if is_selected and not is_allowed: + selected_restricted.append((server, True, False)) + elif is_selected: + selected_allowed.append((server, True, True)) + elif not is_allowed: + continue + elif server.is_available: + available_allowed.append((server, False, True)) + else: + inactive_allowed.append((server, False, True)) + + return selected_restricted + selected_allowed + available_allowed + inactive_allowed + + +def _build_server_selection_view( + user: Optional[User], + servers: Sequence[ServerSquad], + current_squads: Set[str], + user_id: int, + limit: int, +) -> Tuple[Optional[str], Optional[types.InlineKeyboardMarkup], bool]: + prepared = _prepare_server_choices_for_user(user, servers, current_squads) + + if not prepared: + return None, None, False + + has_restricted = any(not is_allowed for _, _, is_allowed in prepared) + + text_lines = [ + "🌍 Управление серверами", + "", + "Нажмите на сервер чтобы добавить/убрать:", + "✅ - выбранный сервер", + "⚪ - доступный сервер", + ] + + if has_restricted: + text_lines.append( + "🚫 - недоступен для промогруппы пользователя (можно только отключить)" + ) + + text_lines.append("🔒 - неактивный (только для уже назначенных)") + text_lines.append("") + + rows: List[List[types.InlineKeyboardButton]] = [] + + for server, is_selected, is_allowed in prepared[:limit]: + if is_selected and not is_allowed: + emoji = "🚫" + label = f"{server.display_name} (недоступен)" + elif is_selected: + emoji = "✅" + label = server.display_name + elif server.is_available: + emoji = "⚪" + label = server.display_name + else: + emoji = "🔒" + label = f"{server.display_name} (неактивен)" + + rows.append([ + types.InlineKeyboardButton( + text=f"{emoji} {label}", + callback_data=f"admin_user_toggle_server_{user_id}_{server.id}", + ) + ]) + + if len(prepared) > limit: + text_lines.append( + f"📝 Показано первых {limit} из {len(prepared)} серверов" + ) + text_lines.append("") + + rows.append([ + types.InlineKeyboardButton( + text="✅ Готово", callback_data=f"admin_user_subscription_{user_id}" + ), + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}" + ), + ]) + + markup = types.InlineKeyboardMarkup(inline_keyboard=rows) + text = "\n".join(text_lines) + + return text, markup, True + + async def _show_servers_for_user( callback: types.CallbackQuery, user_id: int, @@ -1878,71 +2007,38 @@ async def _show_servers_for_user( ): try: user = await get_user_by_id(db, user_id) - current_squads = [] + current_squads_list: List[str] = [] if user and user.subscription: - current_squads = user.subscription.connected_squads or [] - + current_squads_list = list(user.subscription.connected_squads or []) + + current_squads = set(current_squads_list) all_servers, _ = await get_all_server_squads(db, available_only=False) - - servers_to_show = [] - for server in all_servers: - if server.is_available or server.squad_uuid in current_squads: - servers_to_show.append(server) - - if not servers_to_show: + + text, markup, has_servers = _build_server_selection_view( + user, + all_servers, + current_squads, + user_id, + limit=20, + ) + + if not has_servers: await callback.message.edit_text( - "❌ Доступные серверы не найдены", + "❌ Не найдено серверов для текущей промогруппы", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] - ]) + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data=f"admin_user_subscription_{user_id}", + ) + ] + ]), ) return - - text = f"🌍 Управление серверами\n\n" - text += f"Нажмите на сервер чтобы добавить/убрать:\n" - text += f"✅ - выбранный сервер\n" - text += f"⚪ - доступный сервер\n" - text += f"🔒 - неактивный (только для уже назначенных)\n\n" - - keyboard = [] - selected_servers = [s for s in servers_to_show if s.squad_uuid in current_squads] - available_servers = [s for s in servers_to_show if s.squad_uuid not in current_squads and s.is_available] - inactive_servers = [s for s in servers_to_show if s.squad_uuid not in current_squads and not s.is_available] - - sorted_servers = selected_servers + available_servers + inactive_servers - - for server in sorted_servers[:20]: - is_selected = server.squad_uuid in current_squads - - if is_selected: - emoji = "✅" - elif server.is_available: - emoji = "⚪" - else: - emoji = "🔒" - - display_name = server.display_name - if not server.is_available and not is_selected: - display_name += " (неактивный)" - - keyboard.append([ - types.InlineKeyboardButton( - text=f"{emoji} {display_name}", - callback_data=f"admin_user_toggle_server_{user_id}_{server.id}" - ) - ]) - - if len(servers_to_show) > 20: - text += f"\n📝 Показано первых 20 из {len(servers_to_show)} серверов" - - keyboard.append([ - types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_subscription_{user_id}"), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}") - ]) - + await callback.message.edit_text( text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + reply_markup=markup, ) except Exception as e: @@ -1972,11 +2068,21 @@ async def toggle_user_server( subscription = user.subscription current_squads = list(subscription.connected_squads or []) - - if server.squad_uuid in current_squads: + current_squad_set = set(current_squads) + + is_allowed = _is_server_allowed_for_user(server, user) + + if server.squad_uuid in current_squad_set: current_squads.remove(server.squad_uuid) action_text = "удален" else: + if not is_allowed: + await callback.answer( + "❌ Этот сервер недоступен для промогруппы пользователя", + show_alert=True, + ) + return + current_squads.append(server.squad_uuid) action_text = "добавлен" @@ -2018,47 +2124,38 @@ async def refresh_server_selection_screen( ): try: user = await get_user_by_id(db, user_id) - current_squads = [] + current_squads_list: List[str] = [] if user and user.subscription: - current_squads = user.subscription.connected_squads or [] - - servers, _ = await get_all_server_squads(db, available_only=True) - - if not servers: + current_squads_list = list(user.subscription.connected_squads or []) + + current_squads = set(current_squads_list) + servers, _ = await get_all_server_squads(db, available_only=False) + + text, markup, has_servers = _build_server_selection_view( + user, + servers, + current_squads, + user_id, + limit=15, + ) + + if not has_servers: await callback.message.edit_text( - "❌ Доступные серверы не найдены", + "❌ Не найдено серверов для текущей промогруппы", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] - ]) + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data=f"admin_user_subscription_{user_id}", + ) + ] + ]), ) return - - text = f"🌍 Управление серверами\n\n" - text += f"Нажмите на сервер чтобы добавить/убрать:\n\n" - - keyboard = [] - for server in servers[:15]: - is_selected = server.squad_uuid in current_squads - emoji = "✅" if is_selected else "⚪" - - keyboard.append([ - types.InlineKeyboardButton( - text=f"{emoji} {server.display_name}", - callback_data=f"admin_user_toggle_server_{user_id}_{server.id}" - ) - ]) - - if len(servers) > 15: - text += f"\n📝 Показано первых 15 из {len(servers)} серверов" - - keyboard.append([ - types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_subscription_{user_id}"), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}") - ]) - + await callback.message.edit_text( text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + reply_markup=markup, ) except Exception as e: diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 3eeee497..0db04fcd 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -99,7 +99,7 @@ async def _prepare_subscription_summary( ) summary_data = dict(data) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) months_in_period = calculate_months_from_days(summary_data['period_days']) period_display = format_period_description(summary_data['period_days'], db_user.language) @@ -1003,7 +1003,7 @@ async def return_to_saved_cart( from app.utils.pricing_utils import calculate_months_from_days, format_period_description - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) selected_countries_names = [] months_in_period = calculate_months_from_days(data['period_days']) @@ -1043,7 +1043,7 @@ async def handle_add_countries( db: AsyncSession, state: FSMContext ): - if not await _should_show_countries_management(): + if not await _should_show_countries_management(db_user.promo_group_id): await callback.answer("ℹ️ Управление серверами недоступно - доступен только один сервер", show_alert=True) return @@ -1054,7 +1054,7 @@ async def handle_add_countries( await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True) return - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) current_countries = subscription.connected_squads current_countries_names = [] @@ -1151,7 +1151,7 @@ async def handle_manage_country( await state.update_data(countries=current_selected) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) try: await callback.message.edit_reply_markup( @@ -1203,7 +1203,7 @@ async def apply_countries_changes( logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) months_to_pay = get_remaining_months(subscription.end_date) @@ -2527,15 +2527,15 @@ async def select_period( ) await state.set_state(SubscriptionStates.selecting_traffic) else: - if await _should_show_countries_management(): - countries = await _get_available_countries() + if await _should_show_countries_management(db_user.promo_group_id): + countries = await _get_available_countries(db_user.promo_group_id) await callback.message.edit_text( texts.SELECT_COUNTRIES, reply_markup=get_countries_keyboard(countries, [], db_user.language) ) await state.set_state(SubscriptionStates.selecting_countries) else: - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) @@ -2683,15 +2683,15 @@ async def select_traffic( await state.set_data(data) - if await _should_show_countries_management(): - countries = await _get_available_countries() + if await _should_show_countries_management(db_user.promo_group_id): + countries = await _get_available_countries(db_user.promo_group_id) await callback.message.edit_text( texts.SELECT_COUNTRIES, reply_markup=get_countries_keyboard(countries, [], db_user.language) ) await state.set_state(SubscriptionStates.selecting_countries) else: - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) @@ -2722,7 +2722,7 @@ async def select_country( else: selected_countries.append(country_uuid) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) period_base_price = PERIOD_PRICES[data['period_days']] from app.utils.pricing_utils import apply_percentage_discount @@ -2797,7 +2797,7 @@ async def select_devices( settings.get_traffic_price(data['traffic_gb']) ) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) countries_price = sum( c['price_kopeks'] for c in countries if c['uuid'] in data['countries'] @@ -2866,7 +2866,7 @@ async def confirm_purchase( else None ) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) months_in_period = data.get( 'months_in_period', calculate_months_from_days(data['period_days']) @@ -3523,7 +3523,7 @@ async def handle_subscription_settings( Выберите что хотите изменить: """ - show_countries = await _should_show_countries_management() + show_countries = await _should_show_countries_management(db_user.promo_group_id) await callback.message.edit_text( settings_text, @@ -3636,8 +3636,8 @@ async def handle_subscription_config_back( await state.set_state(SubscriptionStates.selecting_period) elif current_state == SubscriptionStates.selecting_devices.state: - if await _should_show_countries_management(): - countries = await _get_available_countries() + if await _should_show_countries_management(db_user.promo_group_id): + countries = await _get_available_countries(db_user.promo_group_id) data = await state.get_data() selected_countries = data.get('countries', []) @@ -3683,68 +3683,99 @@ async def handle_subscription_cancel( await callback.answer("❌ Покупка отменена") -async def _get_available_countries(): - from app.utils.cache import cache +async def _get_available_countries( + promo_group_id: Optional[int] = None, +): + from app.utils.cache import cache, cache_key from app.database.database import AsyncSessionLocal from app.database.crud.server_squad import get_available_server_squads - - cached_countries = await cache.get("available_countries") + + cache_key_value = cache_key( + "available_countries", + promo_group_id if promo_group_id is not None else "all", + ) + + cached_countries = await cache.get(cache_key_value) if cached_countries: return cached_countries - + try: async with AsyncSessionLocal() as db: - available_servers = await get_available_server_squads(db) - + available_servers = await get_available_server_squads( + db, promo_group_id=promo_group_id + ) + countries = [] for server in available_servers: countries.append({ "uuid": server.squad_uuid, - "name": server.display_name, + "name": server.display_name, "price_kopeks": server.price_kopeks, "country_code": server.country_code, - "is_available": server.is_available and not server.is_full + "is_available": server.is_available and not server.is_full, }) - - if not countries: + + if not countries and promo_group_id is None: logger.info("🔄 Серверов в БД нет, получаем из RemnaWave...") from app.services.remnawave_service import RemnaWaveService - + service = RemnaWaveService() squads = await service.get_all_squads() - + for squad in squads: squad_name = squad["name"] - - if not any(flag in squad_name for flag in ["🇳🇱", "🇩🇪", "🇺🇸", "🇫🇷", "🇬🇧", "🇮🇹", "🇪🇸", "🇨🇦", "🇯🇵", "🇸🇬", "🇦🇺"]): + + if not any( + flag in squad_name + for flag in [ + "🇳🇱", + "🇩🇪", + "🇺🇸", + "🇫🇷", + "🇬🇧", + "🇮🇹", + "🇪🇸", + "🇨🇦", + "🇯🇵", + "🇸🇬", + "🇦🇺", + ] + ): name_lower = squad_name.lower() if "netherlands" in name_lower or "нидерланды" in name_lower or "nl" in name_lower: squad_name = f"🇳🇱 {squad_name}" elif "germany" in name_lower or "германия" in name_lower or "de" in name_lower: squad_name = f"🇩🇪 {squad_name}" - elif "usa" in name_lower or "сша" in name_lower or "america" in name_lower or "us" in name_lower: + elif ( + "usa" in name_lower + or "сша" in name_lower + or "america" in name_lower + or "us" in name_lower + ): squad_name = f"🇺🇸 {squad_name}" else: squad_name = f"🌐 {squad_name}" - + countries.append({ "uuid": squad["uuid"], "name": squad_name, - "price_kopeks": 0, - "is_available": True + "price_kopeks": 0, + "is_available": True, }) - - await cache.set("available_countries", countries, 300) - return countries - + + if countries: + await cache.set(cache_key_value, countries, 300) + return countries + except Exception as e: logger.error(f"Ошибка получения списка стран: {e}") - fallback_countries = [ - {"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True}, - ] - - await cache.set("available_countries", fallback_countries, 60) - return fallback_countries + + fallback_countries = [ + {"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True}, + ] + + await cache.set(cache_key_value, fallback_countries, 60) + return fallback_countries async def _get_countries_info(squad_uuids): countries = await _get_available_countries() @@ -3776,7 +3807,7 @@ async def handle_add_country_to_subscription( logger.info(f"🔍 Данные состояния: {data}") selected_countries = data.get('countries', []) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) if country_uuid in selected_countries: selected_countries.remove(country_uuid) @@ -3808,9 +3839,11 @@ async def handle_add_country_to_subscription( await callback.answer() -async def _should_show_countries_management() -> bool: +async def _should_show_countries_management( + promo_group_id: Optional[int] = None, +) -> bool: try: - countries = await _get_available_countries() + countries = await _get_available_countries(promo_group_id) available_countries = [c for c in countries if c.get('is_available', True)] return len(available_countries) > 1 except Exception as e: @@ -3839,7 +3872,7 @@ async def confirm_add_countries_to_subscription( await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) return - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) total_price = 0 new_countries_names = [] removed_countries_names = [] diff --git a/app/states.py b/app/states.py index 782fae7d..f824f9a5 100644 --- a/app/states.py +++ b/app/states.py @@ -95,6 +95,7 @@ class AdminStates(StatesGroup): editing_server_country = State() editing_server_limit = State() editing_server_description = State() + editing_server_promo_groups = State() creating_server_uuid = State() creating_server_name = State() diff --git a/app/utils/cache.py b/app/utils/cache.py index aeed54f7..70b0c5ba 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -179,6 +179,17 @@ async def cached_function(key: str, expire: int = 300): return decorator +async def invalidate_available_countries_cache() -> int: + keys = await cache.get_keys("available_countries*") + deleted_count = 0 + + for key in keys: + if await cache.delete(key): + deleted_count += 1 + + return deleted_count + + class UserCache: @staticmethod From ba79cf292fc824d1dad61d0701a01c7760164b7f Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 17:23:19 +0300 Subject: [PATCH 060/146] Revert "Restrict admin server selection by promo group" --- app/database/crud/promo_group.py | 9 - app/database/crud/server_squad.py | 122 +------ app/database/models.py | 37 +-- app/database/universal_migration.py | 103 ------ app/handlers/admin/servers.py | 495 +++++++--------------------- app/handlers/admin/users.py | 287 ++++++---------- app/handlers/subscription.py | 135 +++----- app/states.py | 1 - app/utils/cache.py | 11 - 9 files changed, 288 insertions(+), 912 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 87a8d186..3bc093f2 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -40,15 +40,6 @@ async def get_promo_groups_with_counts( return result.all() -async def get_all_promo_groups(db: AsyncSession) -> List[PromoGroup]: - result = await db.execute( - select(PromoGroup).order_by( - PromoGroup.is_default.desc(), PromoGroup.name - ) - ) - return result.scalars().all() - - async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]: return await db.get(PromoGroup, group_id) diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index a2b5975e..eb0692e6 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -4,12 +4,7 @@ from sqlalchemy import select, and_, func, update, delete, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import ( - ServerSquad, - SubscriptionServer, - Subscription, - PromoGroup, -) +from app.database.models import ServerSquad, SubscriptionServer, Subscription logger = logging.getLogger(__name__) @@ -23,10 +18,9 @@ async def create_server_squad( price_kopeks: int = 0, description: str = None, max_users: int = None, - is_available: bool = True, - promo_group_ids: Optional[List[int]] = None, + is_available: bool = True ) -> ServerSquad: - + server_squad = ServerSquad( squad_uuid=squad_uuid, display_name=display_name, @@ -35,43 +29,13 @@ async def create_server_squad( price_kopeks=price_kopeks, description=description, max_users=max_users, - is_available=is_available, + is_available=is_available ) - + db.add(server_squad) - await db.flush() - - target_group_ids = [ - group_id for group_id in (promo_group_ids or []) if isinstance(group_id, int) - ] - - if target_group_ids: - groups_result = await db.execute( - select(PromoGroup).where(PromoGroup.id.in_(target_group_ids)) - ) - groups = groups_result.scalars().all() - else: - groups = [] - - if not groups: - default_group_result = await db.execute( - select(PromoGroup).where(PromoGroup.is_default.is_(True)) - ) - default_group = default_group_result.scalars().first() - if default_group: - groups = [default_group] - else: - logger.warning( - "Не найдена базовая промогруппа для сервера %s. Сервер останется без ограничений", - display_name, - ) - - if groups: - server_squad.promo_groups = groups - await db.commit() await db.refresh(server_squad) - + logger.info(f"✅ Создан сервер {display_name} (UUID: {squad_uuid})") return server_squad @@ -82,9 +46,7 @@ async def get_server_squad_by_uuid( ) -> Optional[ServerSquad]: result = await db.execute( - select(ServerSquad) - .options(selectinload(ServerSquad.promo_groups)) - .where(ServerSquad.squad_uuid == squad_uuid) + select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid) ) return result.scalar_one_or_none() @@ -95,9 +57,7 @@ async def get_server_squad_by_id( ) -> Optional[ServerSquad]: result = await db.execute( - select(ServerSquad) - .options(selectinload(ServerSquad.promo_groups)) - .where(ServerSquad.id == server_id) + select(ServerSquad).where(ServerSquad.id == server_id) ) return result.scalar_one_or_none() @@ -109,7 +69,7 @@ async def get_all_server_squads( limit: int = 50 ) -> Tuple[List[ServerSquad], int]: - query = select(ServerSquad).options(selectinload(ServerSquad.promo_groups)) + query = select(ServerSquad) if available_only: query = query.where(ServerSquad.is_available == True) @@ -124,33 +84,21 @@ async def get_all_server_squads( offset = (page - 1) * limit query = query.order_by(ServerSquad.sort_order, ServerSquad.display_name) query = query.offset(offset).limit(limit) - + result = await db.execute(query) - servers = result.scalars().unique().all() - + servers = result.scalars().all() + return servers, total_count -async def get_available_server_squads( - db: AsyncSession, - *, - promo_group_id: Optional[int] = None, -) -> List[ServerSquad]: - - query = ( +async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]: + + result = await db.execute( select(ServerSquad) - .options(selectinload(ServerSquad.promo_groups)) - .where(ServerSquad.is_available.is_(True)) + .where(ServerSquad.is_available == True) .order_by(ServerSquad.sort_order, ServerSquad.display_name) ) - - if promo_group_id is not None: - query = query.join(ServerSquad.promo_groups).where( - PromoGroup.id == promo_group_id - ) - - result = await db.execute(query) - return result.scalars().unique().all() + return result.scalars().all() async def update_server_squad( @@ -176,44 +124,10 @@ async def update_server_squad( ) await db.commit() - + return await get_server_squad_by_id(db, server_id) -async def set_server_squad_promo_groups( - db: AsyncSession, - server_id: int, - promo_group_ids: List[int], -) -> Optional[ServerSquad]: - - if not promo_group_ids: - return None - - server_result = await db.execute( - select(ServerSquad) - .options(selectinload(ServerSquad.promo_groups)) - .where(ServerSquad.id == server_id) - ) - server = server_result.scalar_one_or_none() - - if not server: - return None - - groups_result = await db.execute( - select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids)) - ) - groups = groups_result.scalars().all() - - if not groups: - return None - - server.promo_groups = groups - await db.commit() - await db.refresh(server) - - return server - - async def delete_server_squad(db: AsyncSession, server_id: int) -> bool: connections_result = await db.execute( diff --git a/app/database/models.py b/app/database/models.py index fac7e572..91a7a360 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -15,7 +15,6 @@ from sqlalchemy import ( BigInteger, UniqueConstraint, Index, - Table, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -264,25 +263,6 @@ class Pal24Payment(Base): ) -server_squad_promo_groups = Table( - "server_squad_promo_groups", - Base.metadata, - Column( - "server_squad_id", - Integer, - ForeignKey("server_squads.id", ondelete="CASCADE"), - primary_key=True, - ), - Column( - "promo_group_id", - Integer, - ForeignKey("promo_groups.id", ondelete="CASCADE"), - primary_key=True, - ), - Column("created_at", DateTime, server_default=func.now()), -) - - class PromoGroup(Base): __tablename__ = "promo_groups" @@ -298,11 +278,6 @@ class PromoGroup(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) users = relationship("User", back_populates="promo_group") - server_squads = relationship( - "ServerSquad", - secondary="server_squad_promo_groups", - back_populates="promo_groups", - ) def _get_period_discounts_map(self) -> Dict[int, int]: raw_discounts = self.period_discounts or {} @@ -837,7 +812,7 @@ class BroadcastHistory(Base): class ServerSquad(Base): __tablename__ = "server_squads" - + id = Column(Integer, primary_key=True, index=True) squad_uuid = Column(String(255), unique=True, nullable=False, index=True) @@ -857,16 +832,10 @@ class ServerSquad(Base): sort_order = Column(Integer, default=0) max_users = Column(Integer, nullable=True) - current_users = Column(Integer, default=0) - + current_users = Column(Integer, default=0) + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - promo_groups = relationship( - "PromoGroup", - secondary="server_squad_promo_groups", - back_populates="server_squads", - ) @property def price_rubles(self) -> float: diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index f7395704..522747f0 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -861,102 +861,6 @@ async def ensure_promo_groups_setup(): "users", "auto_promo_group_assigned" ) - -async def ensure_server_squad_promo_groups_link() -> bool: - logger.info("=== НАСТРОЙКА server_squad_promo_groups ===") - - try: - table_exists = await check_table_exists("server_squad_promo_groups") - - async with engine.begin() as conn: - db_type = await get_database_type() - - if not table_exists: - if db_type == "sqlite": - create_sql = """ - CREATE TABLE IF NOT EXISTS server_squad_promo_groups ( - server_squad_id INTEGER NOT NULL, - promo_group_id INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (server_squad_id, promo_group_id), - FOREIGN KEY (server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, - FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE - ) - """ - elif db_type == "postgresql": - create_sql = """ - CREATE TABLE IF NOT EXISTS server_squad_promo_groups ( - server_squad_id INTEGER NOT NULL, - promo_group_id INTEGER NOT NULL, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (server_squad_id, promo_group_id), - FOREIGN KEY (server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, - FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE - ) - """ - elif db_type == "mysql": - create_sql = """ - CREATE TABLE IF NOT EXISTS server_squad_promo_groups ( - server_squad_id INT NOT NULL, - promo_group_id INT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (server_squad_id, promo_group_id), - FOREIGN KEY (server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, - FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE - ) ENGINE=InnoDB - """ - else: - logger.error( - f"Неподдерживаемый тип БД для server_squad_promo_groups: {db_type}" - ) - return False - - await conn.execute(text(create_sql)) - logger.info("Создана таблица server_squad_promo_groups") - - if db_type == "postgresql": - default_query = ( - "SELECT id FROM promo_groups WHERE is_default IS TRUE ORDER BY id LIMIT 1" - ) - else: - default_query = ( - "SELECT id FROM promo_groups WHERE is_default = 1 ORDER BY id LIMIT 1" - ) - - result = await conn.execute(text(default_query)) - row = result.fetchone() - - if not row: - logger.warning("Базовая промогруппа не найдена, пропускаем привязку серверов") - return True - - default_group_id = row[0] - - await conn.execute( - text( - """ - INSERT INTO server_squad_promo_groups (server_squad_id, promo_group_id) - SELECT ss.id, :default_id - FROM server_squads ss - WHERE NOT EXISTS ( - SELECT 1 FROM server_squad_promo_groups spg - WHERE spg.server_squad_id = ss.id - ) - """ - ), - {"default_id": default_group_id}, - ) - - logger.info( - "Серверы без назначенных промогрупп привязаны к базовой промогруппе" - ) - - return True - - except Exception as e: - logger.error(f"Ошибка настройки server_squad_promo_groups: {e}") - return False - if not auto_promo_flag_exists: if db_type == "sqlite": await conn.execute( @@ -1766,13 +1670,6 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с настройкой промо групп") - logger.info("=== ПРИВЯЗКА СЕРВЕРОВ К ПРОМО ГРУППАМ ===") - promo_links_ready = await ensure_server_squad_promo_groups_link() - if promo_links_ready: - logger.info("✅ Серверы привязаны к промогруппам") - else: - logger.warning("⚠️ Проблемы с привязкой серверов к промогруппам") - logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: diff --git a/app/handlers/admin/servers.py b/app/handlers/admin/servers.py index cd8a3ff4..f5ae0023 100644 --- a/app/handlers/admin/servers.py +++ b/app/handlers/admin/servers.py @@ -4,194 +4,19 @@ from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.states import AdminStates -from app.database.models import User, ServerSquad +from app.database.models import User from app.database.crud.server_squad import ( - get_all_server_squads, - get_server_squad_by_id, - update_server_squad, - delete_server_squad, - sync_with_remnawave, - get_server_statistics, - create_server_squad, - get_available_server_squads, - set_server_squad_promo_groups, + get_all_server_squads, get_server_squad_by_id, update_server_squad, + delete_server_squad, sync_with_remnawave, get_server_statistics, + create_server_squad, get_available_server_squads ) -from app.database.crud.promo_group import get_all_promo_groups from app.services.remnawave_service import RemnaWaveService from app.utils.decorators import admin_required, error_handler -from app.utils.cache import cache, invalidate_available_countries_cache +from app.utils.cache import cache logger = logging.getLogger(__name__) -def _format_server_promo_groups(server: ServerSquad) -> str: - if not server.promo_groups: - return "—" - - sorted_groups = sorted( - server.promo_groups, - key=lambda group: ( - not getattr(group, "is_default", False), - group.name.lower(), - ), - ) - - formatted = [] - for group in sorted_groups: - label = group.name - if getattr(group, "is_default", False): - label = f"{label} ⭐" - formatted.append(label) - - return ", ".join(formatted) - - -def _build_server_edit_view(server: ServerSquad): - status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" - price_text = ( - f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" - ) - promo_groups_text = _format_server_promo_groups(server) - - text = f""" -🌐 Редактирование сервера - -Информация: -• ID: {server.id} -• UUID: {server.squad_uuid} -• Название: {server.display_name} -• Оригинальное: {server.original_name or 'Не указано'} -• Статус: {status_emoji} - -Настройки: -• Цена: {price_text} -• Код страны: {server.country_code or 'Не указан'} -• Лимит пользователей: {server.max_users or 'Без лимита'} -• Текущих пользователей: {server.current_users} -• Промогруппы: {promo_groups_text} - -Описание: -{server.description or 'Не указано'} - -Выберите что изменить: -""" - - keyboard = [ - [ - types.InlineKeyboardButton( - text="✏️ Название", - callback_data=f"admin_server_edit_name_{server.id}", - ), - types.InlineKeyboardButton( - text="💰 Цена", - callback_data=f"admin_server_edit_price_{server.id}", - ), - ], - [ - types.InlineKeyboardButton( - text="🌍 Страна", - callback_data=f"admin_server_edit_country_{server.id}", - ), - types.InlineKeyboardButton( - text="👥 Лимит", - callback_data=f"admin_server_edit_limit_{server.id}", - ), - ], - [ - types.InlineKeyboardButton( - text="🎯 Промогруппы", - callback_data=f"admin_server_edit_promos_{server.id}", - ) - ], - [ - types.InlineKeyboardButton( - text="📝 Описание", - callback_data=f"admin_server_edit_desc_{server.id}", - ) - ], - [ - types.InlineKeyboardButton( - text="❌ Отключить" if server.is_available else "✅ Включить", - callback_data=f"admin_server_toggle_{server.id}", - ) - ], - [ - types.InlineKeyboardButton( - text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}" - ), - types.InlineKeyboardButton( - text="⬅️ Назад", callback_data="admin_servers_list" - ), - ], - ] - - return text, types.InlineKeyboardMarkup(inline_keyboard=keyboard) - - -def _build_server_promo_groups_keyboard( - server_id: int, - promo_groups, - selected_ids: set, -): - buttons = [] - - for group in promo_groups: - is_selected = group.id in selected_ids - emoji = "✅" if is_selected else "⚪" - label = group.name - if getattr(group, "is_default", False): - label = f"{label} ⭐" - - buttons.append( - [ - types.InlineKeyboardButton( - text=f"{emoji} {label}", - callback_data=f"admin_server_promos_toggle_{server_id}_{group.id}", - ) - ] - ) - - buttons.append( - [ - types.InlineKeyboardButton( - text="💾 Сохранить", - callback_data=f"admin_server_promos_save_{server_id}", - ) - ] - ) - buttons.append( - [ - types.InlineKeyboardButton( - text="⬅️ Назад", callback_data=f"admin_server_edit_{server_id}" - ) - ] - ) - - return types.InlineKeyboardMarkup(inline_keyboard=buttons) - - -def _build_server_promo_groups_text( - server: ServerSquad, - promo_groups, - selected_ids: set, -) -> str: - selected_names = [ - group.name - for group in promo_groups - if group.id in selected_ids - ] - - selected_display = ", ".join(selected_names) if selected_names else "не выбраны" - - return ( - "🎯 Промогруппы сервера\n\n" - f"Сервер: {server.display_name}\n" - "Выберите промогруппы, которым будет доступен этот сервер.\n\n" - f"Сейчас выбрано: {selected_display}\n\n" - "⚠️ Должна быть активна минимум одна промогруппа." - ) - - @admin_required @error_handler async def show_servers_menu( @@ -340,8 +165,8 @@ async def sync_servers_with_remnawave( return created, updated, disabled = await sync_with_remnawave(db, squads) - - await invalidate_available_countries_cache() + + await cache.delete("available_countries") text = f""" ✅ Синхронизация завершена @@ -385,26 +210,70 @@ async def sync_servers_with_remnawave( @error_handler async def show_server_edit_menu( callback: types.CallbackQuery, - state: FSMContext, db_user: User, - db: AsyncSession, + db: AsyncSession ): - + server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) - + if not server: await callback.answer("❌ Сервер не найден!", show_alert=True) return + + status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" + price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" + + text = f""" +🌐 Редактирование сервера - await state.clear() +Информация: +• ID: {server.id} +• UUID: {server.squad_uuid} +• Название: {server.display_name} +• Оригинальное: {server.original_name or 'Не указано'} +• Статус: {status_emoji} - text, markup = _build_server_edit_view(server) +Настройки: +• Цена: {price_text} +• Код страны: {server.country_code or 'Не указан'} +• Лимит пользователей: {server.max_users or 'Без лимита'} +• Текущих пользователей: {server.current_users} +Описание: +{server.description or 'Не указано'} + +Выберите что изменить: +""" + + keyboard = [ + [ + types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"), + types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}") + ], + [ + types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"), + types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}") + ], + [ + types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}") + ], + [ + types.InlineKeyboardButton( + text="❌ Отключить" if server.is_available else "✅ Включить", + callback_data=f"admin_server_toggle_{server.id}" + ) + ], + [ + types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"), + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list") + ] + ] + await callback.message.edit_text( text, - reply_markup=markup, - parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML" ) await callback.answer() @@ -426,170 +295,68 @@ async def toggle_server_availability( new_status = not server.is_available await update_server_squad(db, server_id, is_available=new_status) - - await invalidate_available_countries_cache() + + await cache.delete("available_countries") status_text = "включен" if new_status else "отключен" await callback.answer(f"✅ Сервер {status_text}!") - + server = await get_server_squad_by_id(db, server_id) + + status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" + price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" + + text = f""" +🌐 Редактирование сервера - text, markup = _build_server_edit_view(server) +Информация: +• ID: {server.id} +• UUID: {server.squad_uuid} +• Название: {server.display_name} +• Оригинальное: {server.original_name or 'Не указано'} +• Статус: {status_emoji} - await callback.message.edit_text( - text, - reply_markup=markup, - parse_mode="HTML", - ) +Настройки: +• Цена: {price_text} +• Код страны: {server.country_code or 'Не указан'} +• Лимит пользователей: {server.max_users or 'Без лимита'} +• Текущих пользователей: {server.current_users} +Описание: +{server.description or 'Не указано'} -@admin_required -@error_handler -async def start_edit_server_promo_groups( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession, -): - - server_id = int(callback.data.split('_')[-1]) - server = await get_server_squad_by_id(db, server_id) - - if not server: - await callback.answer("❌ Сервер не найден!", show_alert=True) - return - - promo_groups = await get_all_promo_groups(db) - - if not promo_groups: - await callback.answer("⚠️ Нет доступных промогрупп", show_alert=True) - return - - selected_ids = { - group.id for group in server.promo_groups if group.id is not None - } - - if not selected_ids: - selected_ids.add(promo_groups[0].id) - - await state.set_state(AdminStates.editing_server_promo_groups) - await state.set_data( - { - "server_id": server_id, - "promo_groups": list(selected_ids), - } - ) - - text = _build_server_promo_groups_text(server, promo_groups, selected_ids) - keyboard = _build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids) - - await callback.message.edit_text( - text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def toggle_server_promo_group( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession, -): - - parts = callback.data.split('_') - - try: - server_id = int(parts[-2]) - group_id = int(parts[-1]) - except (ValueError, IndexError): - await callback.answer("❌ Некорректные данные", show_alert=True) - return - - data = await state.get_data() - - if data.get("server_id") != server_id: - data["server_id"] = server_id - - selected_ids = set(int(i) for i in data.get("promo_groups", [])) - - if group_id in selected_ids: - if len(selected_ids) == 1: - await callback.answer( - "⚠️ Должна быть выбрана минимум одна промогруппа", - show_alert=True, +Выберите что изменить: +""" + + keyboard = [ + [ + types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"), + types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}") + ], + [ + types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"), + types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}") + ], + [ + types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}") + ], + [ + types.InlineKeyboardButton( + text="❌ Отключить" if server.is_available else "✅ Включить", + callback_data=f"admin_server_toggle_{server.id}" ) - return - selected_ids.remove(group_id) - else: - selected_ids.add(group_id) - - await state.update_data( - { - "server_id": server_id, - "promo_groups": list(selected_ids), - } - ) - - server = await get_server_squad_by_id(db, server_id) - promo_groups = await get_all_promo_groups(db) - - if not server or not promo_groups: - await callback.answer("❌ Не удалось обновить данные", show_alert=True) - return - - text = _build_server_promo_groups_text(server, promo_groups, selected_ids) - keyboard = _build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids) - + ], + [ + types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"), + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list") + ] + ] + await callback.message.edit_text( text, - reply_markup=keyboard, - parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML" ) - await callback.answer() - - -@admin_required -@error_handler -async def save_server_promo_groups( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession, -): - - data = await state.get_data() - server_id = int(data.get("server_id", 0)) - promo_group_ids = [int(i) for i in data.get("promo_groups", []) if i] - - if not server_id: - await callback.answer("❌ Сервер не найден", show_alert=True) - return - - if not promo_group_ids: - await callback.answer("⚠️ Выберите хотя бы одну промогруппу", show_alert=True) - return - - server = await set_server_squad_promo_groups(db, server_id, promo_group_ids) - - if not server: - await callback.answer("❌ Не удалось обновить промогруппы", show_alert=True) - return - - await state.clear() - await invalidate_available_countries_cache() - - text, markup = _build_server_edit_view(server) - - await callback.message.edit_text( - text, - reply_markup=markup, - parse_mode="HTML", - ) - await callback.answer("✅ Промогруппы обновлены") @admin_required @@ -651,11 +418,11 @@ async def process_server_price_edit( price_kopeks = int(price_rubles * 100) server = await update_server_squad(db, server_id, price_kopeks=price_kopeks) - + if server: await state.clear() - - await invalidate_available_countries_cache() + + await cache.delete("available_countries") price_text = f"{int(price_rubles)} ₽" if price_kopeks > 0 else "Бесплатно" await message.answer( @@ -726,11 +493,11 @@ async def process_server_name_edit( return server = await update_server_squad(db, server_id, display_name=new_name) - + if server: await state.clear() - - await invalidate_available_countries_cache() + + await cache.delete("available_countries") await message.answer( f"✅ Название сервера изменено на: {new_name}", @@ -801,9 +568,9 @@ async def delete_server_execute( return success = await delete_server_squad(db, server_id) - + if success: - await invalidate_available_countries_cache() + await cache.delete("available_countries") await callback.message.edit_text( f"✅ Сервер {server.display_name} успешно удален!", @@ -930,11 +697,11 @@ async def process_server_country_edit( return server = await update_server_squad(db, server_id, country_code=new_country) - + if server: await state.clear() - - await invalidate_available_countries_cache() + + await cache.delete("available_countries") country_text = new_country or "Удален" await message.answer( @@ -1167,28 +934,8 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(sync_server_user_counts_handler, F.data == "admin_servers_sync_counts") dp.callback_query.register(show_server_detailed_stats, F.data == "admin_servers_stats") - dp.callback_query.register( - show_server_edit_menu, - F.data.startswith("admin_server_edit_") - & ~F.data.contains("name") - & ~F.data.contains("price") - & ~F.data.contains("country") - & ~F.data.contains("limit") - & ~F.data.contains("desc") - & ~F.data.contains("promos"), - ) + dp.callback_query.register(show_server_edit_menu, F.data.startswith("admin_server_edit_") & ~F.data.contains("name") & ~F.data.contains("price") & ~F.data.contains("country") & ~F.data.contains("limit") & ~F.data.contains("desc")) dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_")) - dp.callback_query.register(start_edit_server_promo_groups, F.data.startswith("admin_server_edit_promos_")) - dp.callback_query.register( - toggle_server_promo_group, - F.data.startswith("admin_server_promos_toggle_"), - state=AdminStates.editing_server_promo_groups, - ) - dp.callback_query.register( - save_server_promo_groups, - F.data.startswith("admin_server_promos_save_"), - state=AdminStates.editing_server_promo_groups, - ) dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_")) dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_")) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 9e066135..45fe983f 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -1,7 +1,5 @@ import logging from datetime import datetime, timedelta -from typing import List, Optional, Sequence, Set, Tuple - from aiogram import Dispatcher, types, F from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from aiogram.fsm.context import FSMContext @@ -9,14 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import AdminStates -from app.database.models import ( - User, - UserStatus, - Subscription, - SubscriptionStatus, - TransactionType, - ServerSquad, -) +from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType from app.database.crud.user import get_user_by_id from app.database.crud.campaign import ( get_campaign_registration_by_user, @@ -34,11 +25,7 @@ from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime, format_time_ago from app.services.remnawave_service import RemnaWaveService from app.external.remnawave_api import TrafficLimitStrategy -from app.database.crud.server_squad import ( - get_all_server_squads, - get_server_squad_by_uuid, - get_server_squad_by_id, -) +from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_uuid, get_server_squad_by_id logger = logging.getLogger(__name__) @@ -1884,122 +1871,6 @@ async def show_server_selection( await _show_servers_for_user(callback, user_id, db) await callback.answer() -def _is_server_allowed_for_user(server: ServerSquad, user: Optional[User]) -> bool: - promo_groups = list(getattr(server, "promo_groups", []) or []) - - if not promo_groups: - return True - - user_group_id = getattr(user, "promo_group_id", None) - - if user_group_id is None: - return any(getattr(group, "is_default", False) for group in promo_groups) - - return any(group.id == user_group_id for group in promo_groups) - - -def _prepare_server_choices_for_user( - user: Optional[User], - servers: Sequence[ServerSquad], - current_squads: Set[str], -) -> List[Tuple[ServerSquad, bool, bool]]: - selected_restricted: List[Tuple[ServerSquad, bool, bool]] = [] - selected_allowed: List[Tuple[ServerSquad, bool, bool]] = [] - available_allowed: List[Tuple[ServerSquad, bool, bool]] = [] - inactive_allowed: List[Tuple[ServerSquad, bool, bool]] = [] - - for server in servers: - is_selected = server.squad_uuid in current_squads - is_allowed = _is_server_allowed_for_user(server, user) - - if is_selected and not is_allowed: - selected_restricted.append((server, True, False)) - elif is_selected: - selected_allowed.append((server, True, True)) - elif not is_allowed: - continue - elif server.is_available: - available_allowed.append((server, False, True)) - else: - inactive_allowed.append((server, False, True)) - - return selected_restricted + selected_allowed + available_allowed + inactive_allowed - - -def _build_server_selection_view( - user: Optional[User], - servers: Sequence[ServerSquad], - current_squads: Set[str], - user_id: int, - limit: int, -) -> Tuple[Optional[str], Optional[types.InlineKeyboardMarkup], bool]: - prepared = _prepare_server_choices_for_user(user, servers, current_squads) - - if not prepared: - return None, None, False - - has_restricted = any(not is_allowed for _, _, is_allowed in prepared) - - text_lines = [ - "🌍 Управление серверами", - "", - "Нажмите на сервер чтобы добавить/убрать:", - "✅ - выбранный сервер", - "⚪ - доступный сервер", - ] - - if has_restricted: - text_lines.append( - "🚫 - недоступен для промогруппы пользователя (можно только отключить)" - ) - - text_lines.append("🔒 - неактивный (только для уже назначенных)") - text_lines.append("") - - rows: List[List[types.InlineKeyboardButton]] = [] - - for server, is_selected, is_allowed in prepared[:limit]: - if is_selected and not is_allowed: - emoji = "🚫" - label = f"{server.display_name} (недоступен)" - elif is_selected: - emoji = "✅" - label = server.display_name - elif server.is_available: - emoji = "⚪" - label = server.display_name - else: - emoji = "🔒" - label = f"{server.display_name} (неактивен)" - - rows.append([ - types.InlineKeyboardButton( - text=f"{emoji} {label}", - callback_data=f"admin_user_toggle_server_{user_id}_{server.id}", - ) - ]) - - if len(prepared) > limit: - text_lines.append( - f"📝 Показано первых {limit} из {len(prepared)} серверов" - ) - text_lines.append("") - - rows.append([ - types.InlineKeyboardButton( - text="✅ Готово", callback_data=f"admin_user_subscription_{user_id}" - ), - types.InlineKeyboardButton( - text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}" - ), - ]) - - markup = types.InlineKeyboardMarkup(inline_keyboard=rows) - text = "\n".join(text_lines) - - return text, markup, True - - async def _show_servers_for_user( callback: types.CallbackQuery, user_id: int, @@ -2007,38 +1878,71 @@ async def _show_servers_for_user( ): try: user = await get_user_by_id(db, user_id) - current_squads_list: List[str] = [] + current_squads = [] if user and user.subscription: - current_squads_list = list(user.subscription.connected_squads or []) - - current_squads = set(current_squads_list) + current_squads = user.subscription.connected_squads or [] + all_servers, _ = await get_all_server_squads(db, available_only=False) - - text, markup, has_servers = _build_server_selection_view( - user, - all_servers, - current_squads, - user_id, - limit=20, - ) - - if not has_servers: + + servers_to_show = [] + for server in all_servers: + if server.is_available or server.squad_uuid in current_squads: + servers_to_show.append(server) + + if not servers_to_show: await callback.message.edit_text( - "❌ Не найдено серверов для текущей промогруппы", + "❌ Доступные серверы не найдены", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="⬅️ Назад", - callback_data=f"admin_user_subscription_{user_id}", - ) - ] - ]), + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] + ]) ) return - + + text = f"🌍 Управление серверами\n\n" + text += f"Нажмите на сервер чтобы добавить/убрать:\n" + text += f"✅ - выбранный сервер\n" + text += f"⚪ - доступный сервер\n" + text += f"🔒 - неактивный (только для уже назначенных)\n\n" + + keyboard = [] + selected_servers = [s for s in servers_to_show if s.squad_uuid in current_squads] + available_servers = [s for s in servers_to_show if s.squad_uuid not in current_squads and s.is_available] + inactive_servers = [s for s in servers_to_show if s.squad_uuid not in current_squads and not s.is_available] + + sorted_servers = selected_servers + available_servers + inactive_servers + + for server in sorted_servers[:20]: + is_selected = server.squad_uuid in current_squads + + if is_selected: + emoji = "✅" + elif server.is_available: + emoji = "⚪" + else: + emoji = "🔒" + + display_name = server.display_name + if not server.is_available and not is_selected: + display_name += " (неактивный)" + + keyboard.append([ + types.InlineKeyboardButton( + text=f"{emoji} {display_name}", + callback_data=f"admin_user_toggle_server_{user_id}_{server.id}" + ) + ]) + + if len(servers_to_show) > 20: + text += f"\n📝 Показано первых 20 из {len(servers_to_show)} серверов" + + keyboard.append([ + types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_subscription_{user_id}"), + types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}") + ]) + await callback.message.edit_text( text, - reply_markup=markup, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) ) except Exception as e: @@ -2068,21 +1972,11 @@ async def toggle_user_server( subscription = user.subscription current_squads = list(subscription.connected_squads or []) - current_squad_set = set(current_squads) - - is_allowed = _is_server_allowed_for_user(server, user) - - if server.squad_uuid in current_squad_set: + + if server.squad_uuid in current_squads: current_squads.remove(server.squad_uuid) action_text = "удален" else: - if not is_allowed: - await callback.answer( - "❌ Этот сервер недоступен для промогруппы пользователя", - show_alert=True, - ) - return - current_squads.append(server.squad_uuid) action_text = "добавлен" @@ -2124,38 +2018,47 @@ async def refresh_server_selection_screen( ): try: user = await get_user_by_id(db, user_id) - current_squads_list: List[str] = [] + current_squads = [] if user and user.subscription: - current_squads_list = list(user.subscription.connected_squads or []) - - current_squads = set(current_squads_list) - servers, _ = await get_all_server_squads(db, available_only=False) - - text, markup, has_servers = _build_server_selection_view( - user, - servers, - current_squads, - user_id, - limit=15, - ) - - if not has_servers: + current_squads = user.subscription.connected_squads or [] + + servers, _ = await get_all_server_squads(db, available_only=True) + + if not servers: await callback.message.edit_text( - "❌ Не найдено серверов для текущей промогруппы", + "❌ Доступные серверы не найдены", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="⬅️ Назад", - callback_data=f"admin_user_subscription_{user_id}", - ) - ] - ]), + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] + ]) ) return - + + text = f"🌍 Управление серверами\n\n" + text += f"Нажмите на сервер чтобы добавить/убрать:\n\n" + + keyboard = [] + for server in servers[:15]: + is_selected = server.squad_uuid in current_squads + emoji = "✅" if is_selected else "⚪" + + keyboard.append([ + types.InlineKeyboardButton( + text=f"{emoji} {server.display_name}", + callback_data=f"admin_user_toggle_server_{user_id}_{server.id}" + ) + ]) + + if len(servers) > 15: + text += f"\n📝 Показано первых 15 из {len(servers)} серверов" + + keyboard.append([ + types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_subscription_{user_id}"), + types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}") + ]) + await callback.message.edit_text( text, - reply_markup=markup, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) ) except Exception as e: diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 0db04fcd..3eeee497 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -99,7 +99,7 @@ async def _prepare_subscription_summary( ) summary_data = dict(data) - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() months_in_period = calculate_months_from_days(summary_data['period_days']) period_display = format_period_description(summary_data['period_days'], db_user.language) @@ -1003,7 +1003,7 @@ async def return_to_saved_cart( from app.utils.pricing_utils import calculate_months_from_days, format_period_description - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() selected_countries_names = [] months_in_period = calculate_months_from_days(data['period_days']) @@ -1043,7 +1043,7 @@ async def handle_add_countries( db: AsyncSession, state: FSMContext ): - if not await _should_show_countries_management(db_user.promo_group_id): + if not await _should_show_countries_management(): await callback.answer("ℹ️ Управление серверами недоступно - доступен только один сервер", show_alert=True) return @@ -1054,7 +1054,7 @@ async def handle_add_countries( await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True) return - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() current_countries = subscription.connected_squads current_countries_names = [] @@ -1151,7 +1151,7 @@ async def handle_manage_country( await state.update_data(countries=current_selected) - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() try: await callback.message.edit_reply_markup( @@ -1203,7 +1203,7 @@ async def apply_countries_changes( logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() months_to_pay = get_remaining_months(subscription.end_date) @@ -2527,15 +2527,15 @@ async def select_period( ) await state.set_state(SubscriptionStates.selecting_traffic) else: - if await _should_show_countries_management(db_user.promo_group_id): - countries = await _get_available_countries(db_user.promo_group_id) + if await _should_show_countries_management(): + countries = await _get_available_countries() await callback.message.edit_text( texts.SELECT_COUNTRIES, reply_markup=get_countries_keyboard(countries, [], db_user.language) ) await state.set_state(SubscriptionStates.selecting_countries) else: - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) @@ -2683,15 +2683,15 @@ async def select_traffic( await state.set_data(data) - if await _should_show_countries_management(db_user.promo_group_id): - countries = await _get_available_countries(db_user.promo_group_id) + if await _should_show_countries_management(): + countries = await _get_available_countries() await callback.message.edit_text( texts.SELECT_COUNTRIES, reply_markup=get_countries_keyboard(countries, [], db_user.language) ) await state.set_state(SubscriptionStates.selecting_countries) else: - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) @@ -2722,7 +2722,7 @@ async def select_country( else: selected_countries.append(country_uuid) - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() period_base_price = PERIOD_PRICES[data['period_days']] from app.utils.pricing_utils import apply_percentage_discount @@ -2797,7 +2797,7 @@ async def select_devices( settings.get_traffic_price(data['traffic_gb']) ) - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() countries_price = sum( c['price_kopeks'] for c in countries if c['uuid'] in data['countries'] @@ -2866,7 +2866,7 @@ async def confirm_purchase( else None ) - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() months_in_period = data.get( 'months_in_period', calculate_months_from_days(data['period_days']) @@ -3523,7 +3523,7 @@ async def handle_subscription_settings( Выберите что хотите изменить: """ - show_countries = await _should_show_countries_management(db_user.promo_group_id) + show_countries = await _should_show_countries_management() await callback.message.edit_text( settings_text, @@ -3636,8 +3636,8 @@ async def handle_subscription_config_back( await state.set_state(SubscriptionStates.selecting_period) elif current_state == SubscriptionStates.selecting_devices.state: - if await _should_show_countries_management(db_user.promo_group_id): - countries = await _get_available_countries(db_user.promo_group_id) + if await _should_show_countries_management(): + countries = await _get_available_countries() data = await state.get_data() selected_countries = data.get('countries', []) @@ -3683,99 +3683,68 @@ async def handle_subscription_cancel( await callback.answer("❌ Покупка отменена") -async def _get_available_countries( - promo_group_id: Optional[int] = None, -): - from app.utils.cache import cache, cache_key +async def _get_available_countries(): + from app.utils.cache import cache from app.database.database import AsyncSessionLocal from app.database.crud.server_squad import get_available_server_squads - - cache_key_value = cache_key( - "available_countries", - promo_group_id if promo_group_id is not None else "all", - ) - - cached_countries = await cache.get(cache_key_value) + + cached_countries = await cache.get("available_countries") if cached_countries: return cached_countries - + try: async with AsyncSessionLocal() as db: - available_servers = await get_available_server_squads( - db, promo_group_id=promo_group_id - ) - + available_servers = await get_available_server_squads(db) + countries = [] for server in available_servers: countries.append({ "uuid": server.squad_uuid, - "name": server.display_name, + "name": server.display_name, "price_kopeks": server.price_kopeks, "country_code": server.country_code, - "is_available": server.is_available and not server.is_full, + "is_available": server.is_available and not server.is_full }) - - if not countries and promo_group_id is None: + + if not countries: logger.info("🔄 Серверов в БД нет, получаем из RemnaWave...") from app.services.remnawave_service import RemnaWaveService - + service = RemnaWaveService() squads = await service.get_all_squads() - + for squad in squads: squad_name = squad["name"] - - if not any( - flag in squad_name - for flag in [ - "🇳🇱", - "🇩🇪", - "🇺🇸", - "🇫🇷", - "🇬🇧", - "🇮🇹", - "🇪🇸", - "🇨🇦", - "🇯🇵", - "🇸🇬", - "🇦🇺", - ] - ): + + if not any(flag in squad_name for flag in ["🇳🇱", "🇩🇪", "🇺🇸", "🇫🇷", "🇬🇧", "🇮🇹", "🇪🇸", "🇨🇦", "🇯🇵", "🇸🇬", "🇦🇺"]): name_lower = squad_name.lower() if "netherlands" in name_lower or "нидерланды" in name_lower or "nl" in name_lower: squad_name = f"🇳🇱 {squad_name}" elif "germany" in name_lower or "германия" in name_lower or "de" in name_lower: squad_name = f"🇩🇪 {squad_name}" - elif ( - "usa" in name_lower - or "сша" in name_lower - or "america" in name_lower - or "us" in name_lower - ): + elif "usa" in name_lower or "сша" in name_lower or "america" in name_lower or "us" in name_lower: squad_name = f"🇺🇸 {squad_name}" else: squad_name = f"🌐 {squad_name}" - + countries.append({ "uuid": squad["uuid"], "name": squad_name, - "price_kopeks": 0, - "is_available": True, + "price_kopeks": 0, + "is_available": True }) - - if countries: - await cache.set(cache_key_value, countries, 300) - return countries - + + await cache.set("available_countries", countries, 300) + return countries + except Exception as e: logger.error(f"Ошибка получения списка стран: {e}") - - fallback_countries = [ - {"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True}, - ] - - await cache.set(cache_key_value, fallback_countries, 60) - return fallback_countries + fallback_countries = [ + {"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True}, + ] + + await cache.set("available_countries", fallback_countries, 60) + return fallback_countries async def _get_countries_info(squad_uuids): countries = await _get_available_countries() @@ -3807,7 +3776,7 @@ async def handle_add_country_to_subscription( logger.info(f"🔍 Данные состояния: {data}") selected_countries = data.get('countries', []) - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() if country_uuid in selected_countries: selected_countries.remove(country_uuid) @@ -3839,11 +3808,9 @@ async def handle_add_country_to_subscription( await callback.answer() -async def _should_show_countries_management( - promo_group_id: Optional[int] = None, -) -> bool: +async def _should_show_countries_management() -> bool: try: - countries = await _get_available_countries(promo_group_id) + countries = await _get_available_countries() available_countries = [c for c in countries if c.get('is_available', True)] return len(available_countries) > 1 except Exception as e: @@ -3872,7 +3839,7 @@ async def confirm_add_countries_to_subscription( await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) return - countries = await _get_available_countries(db_user.promo_group_id) + countries = await _get_available_countries() total_price = 0 new_countries_names = [] removed_countries_names = [] diff --git a/app/states.py b/app/states.py index f824f9a5..782fae7d 100644 --- a/app/states.py +++ b/app/states.py @@ -95,7 +95,6 @@ class AdminStates(StatesGroup): editing_server_country = State() editing_server_limit = State() editing_server_description = State() - editing_server_promo_groups = State() creating_server_uuid = State() creating_server_name = State() diff --git a/app/utils/cache.py b/app/utils/cache.py index 70b0c5ba..aeed54f7 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -179,17 +179,6 @@ async def cached_function(key: str, expire: int = 300): return decorator -async def invalidate_available_countries_cache() -> int: - keys = await cache.get_keys("available_countries*") - deleted_count = 0 - - for key in keys: - if await cache.delete(key): - deleted_count += 1 - - return deleted_count - - class UserCache: @staticmethod From be58a1c04e45e94f629d54959dc585bff399fbfd Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 17:24:24 +0300 Subject: [PATCH 061/146] Add promo group access control for servers --- app/database/crud/server_squad.py | 109 +++++++- app/database/models.py | 40 ++- app/database/universal_migration.py | 113 +++++++- app/handlers/admin/servers.py | 400 +++++++++++++++++++-------- app/handlers/subscription.py | 167 +++++++---- app/services/subscription_service.py | 23 +- app/states.py | 1 + app/utils/cache.py | 17 +- 8 files changed, 675 insertions(+), 195 deletions(-) diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index eb0692e6..85f1dc58 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -1,14 +1,22 @@ import logging -from typing import List, Optional, Tuple +from typing import Iterable, List, Optional, Sequence, Tuple + from sqlalchemy import select, and_, func, update, delete, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import ServerSquad, SubscriptionServer, Subscription +from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription logger = logging.getLogger(__name__) +async def _get_default_promo_group_id(db: AsyncSession) -> Optional[int]: + result = await db.execute( + select(PromoGroup.id).where(PromoGroup.is_default.is_(True)).limit(1) + ) + return result.scalar_one_or_none() + + async def create_server_squad( db: AsyncSession, squad_uuid: str, @@ -18,9 +26,30 @@ async def create_server_squad( price_kopeks: int = 0, description: str = None, max_users: int = None, - is_available: bool = True + is_available: bool = True, + promo_group_ids: Optional[Iterable[int]] = None, ) -> ServerSquad: - + + normalized_group_ids: Sequence[int] + if promo_group_ids is None: + default_id = await _get_default_promo_group_id(db) + normalized_group_ids = [default_id] if default_id is not None else [] + else: + normalized_group_ids = [int(pg_id) for pg_id in set(promo_group_ids)] + + if not normalized_group_ids: + raise ValueError("Server squad must be linked to at least one promo group") + + promo_groups_result = await db.execute( + select(PromoGroup).where(PromoGroup.id.in_(normalized_group_ids)) + ) + promo_groups = promo_groups_result.scalars().all() + + if len(promo_groups) != len(normalized_group_ids): + logger.warning( + "Не все промогруппы найдены при создании сервера %s", display_name + ) + server_squad = ServerSquad( squad_uuid=squad_uuid, display_name=display_name, @@ -29,9 +58,10 @@ async def create_server_squad( price_kopeks=price_kopeks, description=description, max_users=max_users, - is_available=is_available + is_available=is_available, + allowed_promo_groups=promo_groups, ) - + db.add(server_squad) await db.commit() await db.refresh(server_squad) @@ -46,9 +76,11 @@ async def get_server_squad_by_uuid( ) -> Optional[ServerSquad]: result = await db.execute( - select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid) + select(ServerSquad) + .options(selectinload(ServerSquad.allowed_promo_groups)) + .where(ServerSquad.squad_uuid == squad_uuid) ) - return result.scalar_one_or_none() + return result.scalars().unique().one_or_none() async def get_server_squad_by_id( @@ -57,9 +89,11 @@ async def get_server_squad_by_id( ) -> Optional[ServerSquad]: result = await db.execute( - select(ServerSquad).where(ServerSquad.id == server_id) + select(ServerSquad) + .options(selectinload(ServerSquad.allowed_promo_groups)) + .where(ServerSquad.id == server_id) ) - return result.scalar_one_or_none() + return result.scalars().unique().one_or_none() async def get_all_server_squads( @@ -91,14 +125,59 @@ async def get_all_server_squads( return servers, total_count -async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]: - - result = await db.execute( +async def get_available_server_squads( + db: AsyncSession, + promo_group_id: Optional[int] = None, +) -> List[ServerSquad]: + + query = ( select(ServerSquad) - .where(ServerSquad.is_available == True) + .options(selectinload(ServerSquad.allowed_promo_groups)) + .where(ServerSquad.is_available.is_(True)) .order_by(ServerSquad.sort_order, ServerSquad.display_name) ) - return result.scalars().all() + + if promo_group_id is not None: + query = query.join(ServerSquad.allowed_promo_groups).where( + PromoGroup.id == promo_group_id + ) + + result = await db.execute(query) + return result.scalars().unique().all() + + +async def update_server_squad_promo_groups( + db: AsyncSession, server_id: int, promo_group_ids: Iterable[int] +) -> Optional[ServerSquad]: + unique_ids = [int(pg_id) for pg_id in set(promo_group_ids)] + + if not unique_ids: + raise ValueError("Нужно выбрать хотя бы одну промогруппу") + + server = await get_server_squad_by_id(db, server_id) + if not server: + return None + + result = await db.execute( + select(PromoGroup).where(PromoGroup.id.in_(unique_ids)) + ) + promo_groups = result.scalars().all() + + if not promo_groups: + raise ValueError("Не найдены промогруппы для обновления сервера") + + server.allowed_promo_groups = promo_groups + await db.commit() + await db.refresh(server) + + logger.info( + "Обновлены промогруппы сервера %s (ID: %s): %s", + server.display_name, + server.id, + ", ".join(pg.name for pg in promo_groups), + ) + + return server async def update_server_squad( diff --git a/app/database/models.py b/app/database/models.py index 91a7a360..0a19fe07 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -15,6 +15,7 @@ from sqlalchemy import ( BigInteger, UniqueConstraint, Index, + Table, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -24,6 +25,24 @@ from sqlalchemy.sql import func Base = declarative_base() +server_squad_promo_groups = Table( + "server_squad_promo_groups", + Base.metadata, + Column( + "server_squad_id", + Integer, + ForeignKey("server_squads.id", ondelete="CASCADE"), + primary_key=True, + ), + Column( + "promo_group_id", + Integer, + ForeignKey("promo_groups.id", ondelete="CASCADE"), + primary_key=True, + ), +) + + class UserStatus(Enum): ACTIVE = "active" BLOCKED = "blocked" @@ -278,6 +297,12 @@ class PromoGroup(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) users = relationship("User", back_populates="promo_group") + server_squads = relationship( + "ServerSquad", + secondary=server_squad_promo_groups, + back_populates="allowed_promo_groups", + lazy="selectin", + ) def _get_period_discounts_map(self) -> Dict[int, int]: raw_discounts = self.period_discounts or {} @@ -812,9 +837,9 @@ class BroadcastHistory(Base): class ServerSquad(Base): __tablename__ = "server_squads" - + id = Column(Integer, primary_key=True, index=True) - + squad_uuid = Column(String(255), unique=True, nullable=False, index=True) display_name = Column(String(255), nullable=False) @@ -832,10 +857,17 @@ class ServerSquad(Base): sort_order = Column(Integer, default=0) max_users = Column(Integer, nullable=True) - current_users = Column(Integer, default=0) - + current_users = Column(Integer, default=0) + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + allowed_promo_groups = relationship( + "PromoGroup", + secondary=server_squad_promo_groups, + back_populates="server_squads", + lazy="selectin", + ) @property def price_rubles(self) -> float: diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 522747f0..04c37925 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1515,14 +1515,114 @@ async def fix_subscription_duplicates_universal(): deleted_count = delete_result.rowcount total_deleted += deleted_count logger.info(f"Удалено {deleted_count} дублирующихся подписок для пользователя {user_id}") - + logger.info(f"Всего удалено дублирующихся подписок: {total_deleted}") return total_deleted - + except Exception as e: logger.error(f"Ошибка при очистке дублирующихся подписок: {e}") raise + +async def ensure_server_promo_groups_setup() -> bool: + logger.info("=== НАСТРОЙКА ДОСТУПА СЕРВЕРОВ К ПРОМОГРУППАМ ===") + + try: + table_exists = await check_table_exists("server_squad_promo_groups") + + async with engine.begin() as conn: + db_type = await get_database_type() + + if not table_exists: + if db_type == "sqlite": + create_sql = """ + CREATE TABLE server_squad_promo_groups ( + server_squad_id INTEGER NOT NULL, + promo_group_id INTEGER NOT NULL, + PRIMARY KEY (server_squad_id, promo_group_id), + FOREIGN KEY (server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, + FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_server_squad_promo_groups_promo ON server_squad_promo_groups(promo_group_id); + """ + elif db_type == "postgresql": + create_sql = """ + CREATE TABLE server_squad_promo_groups ( + server_squad_id INTEGER NOT NULL REFERENCES server_squads(id) ON DELETE CASCADE, + promo_group_id INTEGER NOT NULL REFERENCES promo_groups(id) ON DELETE CASCADE, + PRIMARY KEY (server_squad_id, promo_group_id) + ); + CREATE INDEX IF NOT EXISTS idx_server_squad_promo_groups_promo ON server_squad_promo_groups(promo_group_id); + """ + else: + create_sql = """ + CREATE TABLE server_squad_promo_groups ( + server_squad_id INT NOT NULL, + promo_group_id INT NOT NULL, + PRIMARY KEY (server_squad_id, promo_group_id), + FOREIGN KEY (server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, + FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_server_squad_promo_groups_promo ON server_squad_promo_groups(promo_group_id); + """ + + await conn.execute(text(create_sql)) + logger.info("✅ Таблица server_squad_promo_groups создана") + else: + logger.info("ℹ️ Таблица server_squad_promo_groups уже существует") + + default_query = ( + "SELECT id FROM promo_groups WHERE is_default IS TRUE LIMIT 1" + if db_type == "postgresql" + else "SELECT id FROM promo_groups WHERE is_default = 1 LIMIT 1" + ) + default_result = await conn.execute(text(default_query)) + default_row = default_result.fetchone() + + if not default_row: + logger.warning("⚠️ Не найдена базовая промогруппа для назначения серверам") + return True + + default_group_id = default_row[0] + + servers_result = await conn.execute(text("SELECT id FROM server_squads")) + server_ids = [row[0] for row in servers_result.fetchall()] + + assigned_count = 0 + for server_id in server_ids: + existing = await conn.execute( + text( + "SELECT 1 FROM server_squad_promo_groups WHERE server_squad_id = :sid LIMIT 1" + ), + {"sid": server_id}, + ) + if existing.fetchone(): + continue + + await conn.execute( + text( + "INSERT INTO server_squad_promo_groups (server_squad_id, promo_group_id) " + "VALUES (:sid, :gid)" + ), + {"sid": server_id, "gid": default_group_id}, + ) + assigned_count += 1 + + if assigned_count: + logger.info( + f"✅ Базовая промогруппа назначена {assigned_count} серверам" + ) + else: + logger.info("ℹ️ Все серверы уже имеют назначенные промогруппы") + + return True + + except Exception as e: + logger.error( + f"Ошибка настройки таблицы server_squad_promo_groups: {e}" + ) + return False + async def run_universal_migration(): logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") @@ -1670,6 +1770,12 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с настройкой промо групп") + server_promo_groups_ready = await ensure_server_promo_groups_setup() + if server_promo_groups_ready: + logger.info("✅ Доступ серверов по промогруппам настроен") + else: + logger.warning("⚠️ Проблемы с настройкой доступа серверов к промогруппам") + logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: @@ -1742,6 +1848,7 @@ async def check_migration_status(): "subscription_duplicates": False, "subscription_conversions_table": False, "promo_groups_table": False, + "server_promo_groups_table": False, "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, @@ -1755,6 +1862,7 @@ async def check_migration_status(): status["welcome_texts_table"] = await check_table_exists('welcome_texts') status["subscription_conversions_table"] = await check_table_exists('subscription_conversions') status["promo_groups_table"] = await check_table_exists('promo_groups') + status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups') status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') @@ -1792,6 +1900,7 @@ async def check_migration_status(): "subscription_conversions_table": "Таблица конверсий подписок", "subscription_duplicates": "Отсутствие дубликатов подписок", "promo_groups_table": "Таблица промо-групп", + "server_promo_groups_table": "Связи серверов и промогрупп", "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", diff --git a/app/handlers/admin/servers.py b/app/handlers/admin/servers.py index f5ae0023..9edc3af0 100644 --- a/app/handlers/admin/servers.py +++ b/app/handlers/admin/servers.py @@ -6,10 +6,17 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.states import AdminStates from app.database.models import User from app.database.crud.server_squad import ( - get_all_server_squads, get_server_squad_by_id, update_server_squad, - delete_server_squad, sync_with_remnawave, get_server_statistics, - create_server_squad, get_available_server_squads + get_all_server_squads, + get_server_squad_by_id, + update_server_squad, + delete_server_squad, + sync_with_remnawave, + get_server_statistics, + create_server_squad, + get_available_server_squads, + update_server_squad_promo_groups, ) +from app.database.crud.promo_group import get_promo_groups_with_counts from app.services.remnawave_service import RemnaWaveService from app.utils.decorators import admin_required, error_handler from app.utils.cache import cache @@ -17,6 +24,111 @@ from app.utils.cache import cache logger = logging.getLogger(__name__) +def _build_server_edit_view(server): + status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" + price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" + promo_groups_text = ( + ", ".join(sorted(pg.name for pg in server.allowed_promo_groups)) + if server.allowed_promo_groups + else "Не выбраны" + ) + + text = f""" +🌐 Редактирование сервера + +Информация: +• ID: {server.id} +• UUID: {server.squad_uuid} +• Название: {server.display_name} +• Оригинальное: {server.original_name or 'Не указано'} +• Статус: {status_emoji} + +Настройки: +• Цена: {price_text} +• Код страны: {server.country_code or 'Не указан'} +• Лимит пользователей: {server.max_users or 'Без лимита'} +• Текущих пользователей: {server.current_users} +• Промогруппы: {promo_groups_text} + +Описание: +{server.description or 'Не указано'} + +Выберите что изменить: +""" + + keyboard = [ + [ + types.InlineKeyboardButton( + text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}" + ), + types.InlineKeyboardButton( + text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}" + ), + ], + [ + types.InlineKeyboardButton( + text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}" + ), + types.InlineKeyboardButton( + text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}" + ), + ], + [ + types.InlineKeyboardButton( + text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}" + ), + types.InlineKeyboardButton( + text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}" + ), + ], + [ + types.InlineKeyboardButton( + text="❌ Отключить" if server.is_available else "✅ Включить", + callback_data=f"admin_server_toggle_{server.id}", + ) + ], + [ + types.InlineKeyboardButton( + text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}" + ), + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list"), + ], + ] + + return text, types.InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def _build_server_promo_groups_keyboard(server_id: int, promo_groups, selected_ids): + keyboard = [] + for group in promo_groups: + emoji = "✅" if group["id"] in selected_ids else "⚪" + keyboard.append( + [ + types.InlineKeyboardButton( + text=f"{emoji} {group['name']}", + callback_data=f"admin_server_promo_toggle_{server_id}_{group['id']}", + ) + ] + ) + + keyboard.append( + [ + types.InlineKeyboardButton( + text="💾 Сохранить", callback_data=f"admin_server_promo_save_{server_id}" + ) + ] + ) + keyboard.append( + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data=f"admin_server_edit_{server_id}" + ) + ] + ) + + return types.InlineKeyboardMarkup(inline_keyboard=keyboard) + + @admin_required @error_handler async def show_servers_menu( @@ -166,7 +278,7 @@ async def sync_servers_with_remnawave( created, updated, disabled = await sync_with_remnawave(db, squads) - await cache.delete("available_countries") + await cache.delete_pattern("available_countries*") text = f""" ✅ Синхронизация завершена @@ -216,63 +328,16 @@ async def show_server_edit_menu( server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) - + if not server: await callback.answer("❌ Сервер не найден!", show_alert=True) return - - status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" - price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" - - text = f""" -🌐 Редактирование сервера -Информация: -• ID: {server.id} -• UUID: {server.squad_uuid} -• Название: {server.display_name} -• Оригинальное: {server.original_name or 'Не указано'} -• Статус: {status_emoji} + text, keyboard = _build_server_edit_view(server) -Настройки: -• Цена: {price_text} -• Код страны: {server.country_code or 'Не указан'} -• Лимит пользователей: {server.max_users or 'Без лимита'} -• Текущих пользователей: {server.current_users} - -Описание: -{server.description or 'Не указано'} - -Выберите что изменить: -""" - - keyboard = [ - [ - types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"), - types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}") - ], - [ - types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"), - types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}") - ], - [ - types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}") - ], - [ - types.InlineKeyboardButton( - text="❌ Отключить" if server.is_available else "✅ Включить", - callback_data=f"admin_server_toggle_{server.id}" - ) - ], - [ - types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list") - ] - ] - await callback.message.edit_text( text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + reply_markup=keyboard, parse_mode="HTML" ) await callback.answer() @@ -296,65 +361,18 @@ async def toggle_server_availability( new_status = not server.is_available await update_server_squad(db, server_id, is_available=new_status) - await cache.delete("available_countries") + await cache.delete_pattern("available_countries*") status_text = "включен" if new_status else "отключен" await callback.answer(f"✅ Сервер {status_text}!") server = await get_server_squad_by_id(db, server_id) - status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" - price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" - - text = f""" -🌐 Редактирование сервера + text, keyboard = _build_server_edit_view(server) -Информация: -• ID: {server.id} -• UUID: {server.squad_uuid} -• Название: {server.display_name} -• Оригинальное: {server.original_name or 'Не указано'} -• Статус: {status_emoji} - -Настройки: -• Цена: {price_text} -• Код страны: {server.country_code or 'Не указан'} -• Лимит пользователей: {server.max_users or 'Без лимита'} -• Текущих пользователей: {server.current_users} - -Описание: -{server.description or 'Не указано'} - -Выберите что изменить: -""" - - keyboard = [ - [ - types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"), - types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}") - ], - [ - types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"), - types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}") - ], - [ - types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}") - ], - [ - types.InlineKeyboardButton( - text="❌ Отключить" if server.is_available else "✅ Включить", - callback_data=f"admin_server_toggle_{server.id}" - ) - ], - [ - types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list") - ] - ] - await callback.message.edit_text( text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + reply_markup=keyboard, parse_mode="HTML" ) @@ -422,7 +440,7 @@ async def process_server_price_edit( if server: await state.clear() - await cache.delete("available_countries") + await cache.delete_pattern("available_countries*") price_text = f"{int(price_rubles)} ₽" if price_kopeks > 0 else "Бесплатно" await message.answer( @@ -497,7 +515,7 @@ async def process_server_name_edit( if server: await state.clear() - await cache.delete("available_countries") + await cache.delete_pattern("available_countries*") await message.answer( f"✅ Название сервера изменено на: {new_name}", @@ -570,7 +588,7 @@ async def delete_server_execute( success = await delete_server_squad(db, server_id) if success: - await cache.delete("available_countries") + await cache.delete_pattern("available_countries*") await callback.message.edit_text( f"✅ Сервер {server.display_name} успешно удален!", @@ -701,7 +719,7 @@ async def process_server_country_edit( if server: await state.clear() - await cache.delete("available_countries") + await cache.delete_pattern("available_countries*") country_text = new_country or "Удален" await message.answer( @@ -847,11 +865,12 @@ async def process_server_description_edit( return server = await update_server_squad(db, server_id, description=new_description) - + if server: await state.clear() - + desc_text = new_description or "Удалено" + await cache.delete_pattern("available_countries*") await message.answer( f"✅ Описание сервера изменено:\n\n{desc_text}", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ @@ -862,6 +881,147 @@ async def process_server_description_edit( else: await message.answer("❌ Ошибка при обновлении сервера") + +@admin_required +@error_handler +async def start_server_edit_promo_groups( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession, +): + + server_id = int(callback.data.split('_')[-1]) + server = await get_server_squad_by_id(db, server_id) + + if not server: + await callback.answer("❌ Сервер не найден!", show_alert=True) + return + + promo_groups_data = await get_promo_groups_with_counts(db) + promo_groups = [ + {"id": group.id, "name": group.name, "is_default": group.is_default} + for group, _ in promo_groups_data + ] + + if not promo_groups: + await callback.answer("❌ Не найдены промогруппы", show_alert=True) + return + + selected_ids = {pg.id for pg in server.allowed_promo_groups} + if not selected_ids: + default_group = next((pg for pg in promo_groups if pg["is_default"]), None) + if default_group: + selected_ids.add(default_group["id"]) + + await state.set_state(AdminStates.editing_server_promo_groups) + await state.set_data( + { + "server_id": server_id, + "promo_groups": promo_groups, + "selected_promo_groups": list(selected_ids), + "server_name": server.display_name, + } + ) + + text = ( + "🎯 Настройка промогрупп\n\n" + f"Сервер: {server.display_name}\n\n" + "Выберите промогруппы, которым будет доступен этот сервер.\n" + "Должна быть выбрана минимум одна промогруппа." + ) + + await callback.message.edit_text( + text, + reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_server_promo_group( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession, +): + + parts = callback.data.split('_') + server_id = int(parts[4]) + group_id = int(parts[5]) + + data = await state.get_data() + if not data or data.get("server_id") != server_id: + await callback.answer("⚠️ Сессия редактирования устарела", show_alert=True) + return + + selected = set(int(pg_id) for pg_id in data.get("selected_promo_groups", [])) + promo_groups = data.get("promo_groups", []) + + if group_id in selected: + if len(selected) == 1: + await callback.answer("⚠️ Нельзя отключить последнюю промогруппу", show_alert=True) + return + selected.remove(group_id) + message = "Промогруппа отключена" + else: + selected.add(group_id) + message = "Промогруппа добавлена" + + await state.update_data(selected_promo_groups=list(selected)) + + await callback.message.edit_reply_markup( + reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected) + ) + await callback.answer(message) + + +@admin_required +@error_handler +async def save_server_promo_groups( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession, +): + + data = await state.get_data() + if not data: + await callback.answer("⚠️ Нет данных для сохранения", show_alert=True) + return + + server_id = data.get("server_id") + selected = data.get("selected_promo_groups", []) + + if not selected: + await callback.answer("❌ Выберите хотя бы одну промогруппу", show_alert=True) + return + + try: + server = await update_server_squad_promo_groups(db, server_id, selected) + except ValueError as exc: + await callback.answer(f"❌ {exc}", show_alert=True) + return + + if not server: + await callback.answer("❌ Сервер не найден", show_alert=True) + return + + await cache.delete_pattern("available_countries*") + await state.clear() + + text, keyboard = _build_server_edit_view(server) + + await callback.message.edit_text( + text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await callback.answer("✅ Промогруппы обновлены!") + + @admin_required @error_handler async def sync_server_user_counts_handler( @@ -934,12 +1094,22 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(sync_server_user_counts_handler, F.data == "admin_servers_sync_counts") dp.callback_query.register(show_server_detailed_stats, F.data == "admin_servers_stats") - dp.callback_query.register(show_server_edit_menu, F.data.startswith("admin_server_edit_") & ~F.data.contains("name") & ~F.data.contains("price") & ~F.data.contains("country") & ~F.data.contains("limit") & ~F.data.contains("desc")) + dp.callback_query.register( + show_server_edit_menu, + F.data.startswith("admin_server_edit_") + & ~F.data.contains("name") + & ~F.data.contains("price") + & ~F.data.contains("country") + & ~F.data.contains("limit") + & ~F.data.contains("desc") + & ~F.data.contains("promo"), + ) dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_")) - + dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_")) dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_")) - dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_")) + dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_")) + dp.callback_query.register(start_server_edit_promo_groups, F.data.startswith("admin_server_edit_promo_")) dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_")) dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_")) @@ -947,7 +1117,9 @@ def register_handlers(dp: Dispatcher): dp.message.register(process_server_price_edit, AdminStates.editing_server_price) dp.message.register(process_server_country_edit, AdminStates.editing_server_country) dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit) - dp.message.register(process_server_description_edit, AdminStates.editing_server_description) + dp.message.register(process_server_description_edit, AdminStates.editing_server_description) + dp.callback_query.register(toggle_server_promo_group, F.data.startswith("admin_server_promo_toggle_")) + dp.callback_query.register(save_server_promo_groups, F.data.startswith("admin_server_promo_save_")) dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm")) dp.callback_query.register(delete_server_execute, F.data.startswith("admin_server_delete_confirm_")) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 3eeee497..4c0b14a2 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -99,7 +99,7 @@ async def _prepare_subscription_summary( ) summary_data = dict(data) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) months_in_period = calculate_months_from_days(summary_data['period_days']) period_display = format_period_description(summary_data['period_days'], db_user.language) @@ -695,6 +695,8 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: except AttributeError: owner = None + promo_group_id = getattr(owner, "promo_group_id", None) if owner else None + period_discount_percent = 0 if owner: try: @@ -711,11 +713,15 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: try: servers_cost, _ = await subscription_service.get_countries_price_by_uuids( - subscription.connected_squads, db + subscription.connected_squads, + db, + promo_group_id=promo_group_id, ) except AttributeError: servers_cost, _ = await get_countries_price_by_uuids_fallback( - subscription.connected_squads, db + subscription.connected_squads, + db, + promo_group_id=promo_group_id, ) traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb) @@ -1003,7 +1009,7 @@ async def return_to_saved_cart( from app.utils.pricing_utils import calculate_months_from_days, format_period_description - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) selected_countries_names = [] months_in_period = calculate_months_from_days(data['period_days']) @@ -1043,7 +1049,7 @@ async def handle_add_countries( db: AsyncSession, state: FSMContext ): - if not await _should_show_countries_management(): + if not await _should_show_countries_management(db_user): await callback.answer("ℹ️ Управление серверами недоступно - доступен только один сервер", show_alert=True) return @@ -1054,7 +1060,7 @@ async def handle_add_countries( await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True) return - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) current_countries = subscription.connected_squads current_countries_names = [] @@ -1092,7 +1098,11 @@ async def handle_add_countries( await callback.answer() -async def get_countries_price_by_uuids_fallback(country_uuids: List[str], db: AsyncSession) -> Tuple[int, List[int]]: +async def get_countries_price_by_uuids_fallback( + country_uuids: List[str], + db: AsyncSession, + promo_group_id: Optional[int] = None, +) -> Tuple[int, List[int]]: try: from app.database.crud.server_squad import get_server_squad_by_uuid @@ -1102,7 +1112,12 @@ async def get_countries_price_by_uuids_fallback(country_uuids: List[str], db: As for country_uuid in country_uuids: try: server = await get_server_squad_by_uuid(db, country_uuid) - if server and server.is_available and not server.is_full: + is_allowed = True + if promo_group_id is not None and server: + allowed_ids = {pg.id for pg in server.allowed_promo_groups} + is_allowed = promo_group_id in allowed_ids + + if server and server.is_available and not server.is_full and is_allowed: price = server.price_kopeks total_price += price prices_list.append(price) @@ -1136,27 +1151,32 @@ async def handle_manage_country( if not subscription or subscription.is_trial: await callback.answer("⚠ Только для платных подписок", show_alert=True) return - + data = await state.get_data() current_selected = data.get('countries', subscription.connected_squads.copy()) - + + countries = await _get_available_countries(db_user.promo_group_id) + allowed_country_ids = {country['uuid'] for country in countries} + + if country_uuid not in allowed_country_ids and country_uuid not in current_selected: + await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) + return + if country_uuid in current_selected: current_selected.remove(country_uuid) action = "removed" else: current_selected.append(country_uuid) action = "added" - + logger.info(f"🔍 Страна {country_uuid} {action}") - + await state.update_data(countries=current_selected) - - countries = await _get_available_countries() - + try: await callback.message.edit_reply_markup( reply_markup=get_manage_countries_keyboard( - countries, + countries, current_selected, subscription.connected_squads, db_user.language, @@ -1193,18 +1213,25 @@ async def apply_countries_changes( selected_countries = data.get('countries', []) current_countries = subscription.connected_squads - + + countries = await _get_available_countries(db_user.promo_group_id) + allowed_country_ids = {country['uuid'] for country in countries} + + selected_countries = [ + country_uuid + for country_uuid in selected_countries + if country_uuid in allowed_country_ids or country_uuid in current_countries + ] + added = [c for c in selected_countries if c not in current_countries] removed = [c for c in current_countries if c not in selected_countries] - + if not added and not removed: await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) return - + logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") - - countries = await _get_available_countries() - + months_to_pay = get_remaining_months(subscription.end_date) cost_per_month = 0 @@ -1910,7 +1937,9 @@ async def handle_extend_subscription( ) servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids( - subscription.connected_squads, db + subscription.connected_squads, + db, + promo_group_id=db_user.promo_group_id, ) servers_discount_percent = db_user.get_promo_discount( "servers", @@ -2173,7 +2202,9 @@ async def confirm_extend_subscription( subscription_service = SubscriptionService() servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids( - subscription.connected_squads, db + subscription.connected_squads, + db, + promo_group_id=db_user.promo_group_id, ) servers_discount_percent = db_user.get_promo_discount( "servers", @@ -2527,15 +2558,15 @@ async def select_period( ) await state.set_state(SubscriptionStates.selecting_traffic) else: - if await _should_show_countries_management(): - countries = await _get_available_countries() + if await _should_show_countries_management(db_user): + countries = await _get_available_countries(db_user.promo_group_id) await callback.message.edit_text( texts.SELECT_COUNTRIES, reply_markup=get_countries_keyboard(countries, [], db_user.language) ) await state.set_state(SubscriptionStates.selecting_countries) else: - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) @@ -2683,15 +2714,15 @@ async def select_traffic( await state.set_data(data) - if await _should_show_countries_management(): - countries = await _get_available_countries() + if await _should_show_countries_management(db_user): + countries = await _get_available_countries(db_user.promo_group_id) await callback.message.edit_text( texts.SELECT_COUNTRIES, reply_markup=get_countries_keyboard(countries, [], db_user.language) ) await state.set_state(SubscriptionStates.selecting_countries) else: - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) @@ -2722,7 +2753,12 @@ async def select_country( else: selected_countries.append(country_uuid) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) + allowed_country_ids = {country['uuid'] for country in countries} + + if country_uuid not in allowed_country_ids and country_uuid not in selected_countries: + await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) + return period_base_price = PERIOD_PRICES[data['period_days']] from app.utils.pricing_utils import apply_percentage_discount @@ -2736,10 +2772,18 @@ async def select_country( try: subscription_service = SubscriptionService() - countries_price, _ = await subscription_service.get_countries_price_by_uuids(selected_countries, db) + countries_price, _ = await subscription_service.get_countries_price_by_uuids( + selected_countries, + db, + promo_group_id=db_user.promo_group_id, + ) except AttributeError: logger.warning("Используем fallback функцию для расчета цен стран") - countries_price, _ = await get_countries_price_by_uuids_fallback(selected_countries, db) + countries_price, _ = await get_countries_price_by_uuids_fallback( + selected_countries, + db, + promo_group_id=db_user.promo_group_id, + ) data['countries'] = selected_countries data['total_price'] = base_price + countries_price @@ -2797,7 +2841,7 @@ async def select_devices( settings.get_traffic_price(data['traffic_gb']) ) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) countries_price = sum( c['price_kopeks'] for c in countries if c['uuid'] in data['countries'] @@ -2866,7 +2910,7 @@ async def confirm_purchase( else None ) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) months_in_period = data.get( 'months_in_period', calculate_months_from_days(data['period_days']) @@ -3523,7 +3567,7 @@ async def handle_subscription_settings( Выберите что хотите изменить: """ - show_countries = await _should_show_countries_management() + show_countries = await _should_show_countries_management(db_user) await callback.message.edit_text( settings_text, @@ -3636,8 +3680,8 @@ async def handle_subscription_config_back( await state.set_state(SubscriptionStates.selecting_period) elif current_state == SubscriptionStates.selecting_devices.state: - if await _should_show_countries_management(): - countries = await _get_available_countries() + if await _should_show_countries_management(db_user): + countries = await _get_available_countries(db_user.promo_group_id) data = await state.get_data() selected_countries = data.get('countries', []) @@ -3683,18 +3727,21 @@ async def handle_subscription_cancel( await callback.answer("❌ Покупка отменена") -async def _get_available_countries(): - from app.utils.cache import cache +async def _get_available_countries(promo_group_id: Optional[int] = None): + from app.utils.cache import cache, cache_key from app.database.database import AsyncSessionLocal from app.database.crud.server_squad import get_available_server_squads - - cached_countries = await cache.get("available_countries") + + cache_key_value = cache_key("available_countries", promo_group_id or "all") + cached_countries = await cache.get(cache_key_value) if cached_countries: return cached_countries - + try: async with AsyncSessionLocal() as db: - available_servers = await get_available_server_squads(db) + available_servers = await get_available_server_squads( + db, promo_group_id=promo_group_id + ) countries = [] for server in available_servers: @@ -3734,16 +3781,16 @@ async def _get_available_countries(): "is_available": True }) - await cache.set("available_countries", countries, 300) + await cache.set(cache_key_value, countries, 300) return countries - + except Exception as e: logger.error(f"Ошибка получения списка стран: {e}") fallback_countries = [ {"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True}, ] - - await cache.set("available_countries", fallback_countries, 60) + + await cache.set(cache_key_value, fallback_countries, 60) return fallback_countries async def _get_countries_info(squad_uuids): @@ -3776,7 +3823,12 @@ async def handle_add_country_to_subscription( logger.info(f"🔍 Данные состояния: {data}") selected_countries = data.get('countries', []) - countries = await _get_available_countries() + countries = await _get_available_countries(db_user.promo_group_id) + allowed_country_ids = {country['uuid'] for country in countries} + + if country_uuid not in allowed_country_ids and country_uuid not in selected_countries: + await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) + return if country_uuid in selected_countries: selected_countries.remove(country_uuid) @@ -3808,9 +3860,10 @@ async def handle_add_country_to_subscription( await callback.answer() -async def _should_show_countries_management() -> bool: +async def _should_show_countries_management(user: Optional[User] = None) -> bool: try: - countries = await _get_available_countries() + promo_group_id = user.promo_group_id if user else None + countries = await _get_available_countries(promo_group_id) available_countries = [c for c in countries if c.get('is_available', True)] return len(available_countries) > 1 except Exception as e: @@ -3831,7 +3884,16 @@ async def confirm_add_countries_to_subscription( selected_countries = data.get('countries', []) current_countries = subscription.connected_squads - + + countries = await _get_available_countries(db_user.promo_group_id) + allowed_country_ids = {country['uuid'] for country in countries} + + selected_countries = [ + country_uuid + for country_uuid in selected_countries + if country_uuid in allowed_country_ids or country_uuid in current_countries + ] + new_countries = [c for c in selected_countries if c not in current_countries] removed_countries = [c for c in current_countries if c not in selected_countries] @@ -3839,7 +3901,6 @@ async def confirm_add_countries_to_subscription( await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) return - countries = await _get_available_countries() total_price = 0 new_countries_names = [] removed_countries_names = [] diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index e21e259c..7e25c427 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -412,7 +412,9 @@ class SubscriptionService: promo_group = promo_group or (user.promo_group if user else None) servers_price, _ = await self.get_countries_price_by_uuids( - subscription.connected_squads, db + subscription.connected_squads, + db, + promo_group_id=promo_group.id if promo_group else None, ) servers_discount_percent = _resolve_discount_percent( @@ -547,9 +549,11 @@ class SubscriptionService: return False async def get_countries_price_by_uuids( - self, - country_uuids: List[str], - db: AsyncSession + self, + country_uuids: List[str], + db: AsyncSession, + *, + promo_group_id: Optional[int] = None, ) -> Tuple[int, List[int]]: try: from app.database.crud.server_squad import get_server_squad_by_uuid @@ -559,7 +563,12 @@ class SubscriptionService: for country_uuid in country_uuids: server = await get_server_squad_by_uuid(db, country_uuid) - if server and server.is_available and not server.is_full: + is_allowed = True + if promo_group_id is not None and server: + allowed_ids = {pg.id for pg in server.allowed_promo_groups} + is_allowed = promo_group_id in allowed_ids + + if server and server.is_available and not server.is_full and is_allowed: price = server.price_kopeks total_price += price prices_list.append(price) @@ -731,7 +740,9 @@ class SubscriptionService: promo_group = promo_group or (user.promo_group if user else None) servers_price_per_month, _ = await self.get_countries_price_by_uuids( - subscription.connected_squads, db + subscription.connected_squads, + db, + promo_group_id=promo_group.id if promo_group else None, ) servers_discount_percent = _resolve_discount_percent( user, diff --git a/app/states.py b/app/states.py index 782fae7d..f824f9a5 100644 --- a/app/states.py +++ b/app/states.py @@ -95,6 +95,7 @@ class AdminStates(StatesGroup): editing_server_country = State() editing_server_limit = State() editing_server_description = State() + editing_server_promo_groups = State() creating_server_uuid = State() creating_server_name = State() diff --git a/app/utils/cache.py b/app/utils/cache.py index aeed54f7..88eaa1cf 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -67,13 +67,28 @@ class CacheService: async def delete(self, key: str) -> bool: if not self._connected: return False - + try: deleted = await self.redis_client.delete(key) return deleted > 0 except Exception as e: logger.error(f"Ошибка удаления из кеша {key}: {e}") return False + + async def delete_pattern(self, pattern: str) -> int: + if not self._connected: + return 0 + + try: + keys = await self.redis_client.keys(pattern) + if not keys: + return 0 + + deleted = await self.redis_client.delete(*keys) + return int(deleted) + except Exception as e: + logger.error(f"Ошибка удаления ключей по шаблону {pattern}: {e}") + return 0 async def exists(self, key: str) -> bool: if not self._connected: From 9512813b58ebf89a31096eb8f9d9f8f427b0c953 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 18:00:09 +0300 Subject: [PATCH 062/146] Handle missing subscription URL in main menu keyboard --- app/keyboards/inline.py | 45 ++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 53fa2f62..c367b562 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -87,13 +87,24 @@ def get_main_menu_keyboard( if has_active_subscription and subscription_is_active: connect_mode = settings.CONNECT_BUTTON_MODE + subscription_url = getattr(subscription, "subscription_url", None) + + def _fallback_connect_button() -> InlineKeyboardButton: + return InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect", + ) + if connect_mode == "miniapp_subscription": - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription.subscription_url) - ) - ]) + if subscription_url: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + web_app=types.WebAppInfo(url=subscription_url) + ) + ]) + else: + keyboard.append([_fallback_connect_button()]) elif connect_mode == "miniapp_custom": keyboard.append([ InlineKeyboardButton( @@ -102,19 +113,17 @@ def get_main_menu_keyboard( ) ]) elif connect_mode == "link": - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription.subscription_url - ) - ]) + if subscription_url: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=subscription_url + ) + ]) + else: + keyboard.append([_fallback_connect_button()]) else: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect" - ) - ]) + keyboard.append([_fallback_connect_button()]) keyboard.append([ InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"), From c072b4a9329b5f5ef3c47aca764b0657fe6421f6 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 18:40:24 +0300 Subject: [PATCH 063/146] Split promo group table creation statements --- app/database/universal_migration.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 04c37925..01540ea5 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1535,7 +1535,7 @@ async def ensure_server_promo_groups_setup() -> bool: if not table_exists: if db_type == "sqlite": - create_sql = """ + create_table_sql = """ CREATE TABLE server_squad_promo_groups ( server_squad_id INTEGER NOT NULL, promo_group_id INTEGER NOT NULL, @@ -1543,19 +1543,23 @@ async def ensure_server_promo_groups_setup() -> bool: FOREIGN KEY (server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE ); + """ + create_index_sql = """ CREATE INDEX IF NOT EXISTS idx_server_squad_promo_groups_promo ON server_squad_promo_groups(promo_group_id); """ elif db_type == "postgresql": - create_sql = """ + create_table_sql = """ CREATE TABLE server_squad_promo_groups ( server_squad_id INTEGER NOT NULL REFERENCES server_squads(id) ON DELETE CASCADE, promo_group_id INTEGER NOT NULL REFERENCES promo_groups(id) ON DELETE CASCADE, PRIMARY KEY (server_squad_id, promo_group_id) ); + """ + create_index_sql = """ CREATE INDEX IF NOT EXISTS idx_server_squad_promo_groups_promo ON server_squad_promo_groups(promo_group_id); """ else: - create_sql = """ + create_table_sql = """ CREATE TABLE server_squad_promo_groups ( server_squad_id INT NOT NULL, promo_group_id INT NOT NULL, @@ -1563,10 +1567,13 @@ async def ensure_server_promo_groups_setup() -> bool: FOREIGN KEY (server_squad_id) REFERENCES server_squads(id) ON DELETE CASCADE, FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE ); + """ + create_index_sql = """ CREATE INDEX IF NOT EXISTS idx_server_squad_promo_groups_promo ON server_squad_promo_groups(promo_group_id); """ - await conn.execute(text(create_sql)) + await conn.execute(text(create_table_sql)) + await conn.execute(text(create_index_sql)) logger.info("✅ Таблица server_squad_promo_groups создана") else: logger.info("ℹ️ Таблица server_squad_promo_groups уже существует") From 1f376d4b5d9a8ae758c7cc56f52cf09295a13002 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 20:54:09 +0300 Subject: [PATCH 064/146] Ensure mulenpay schema has mulen_payment_id column --- app/database/universal_migration.py | 75 +++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 01540ea5..c24503d8 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -377,6 +377,75 @@ async def create_mulenpay_payments_table(): return False +async def ensure_mulenpay_payment_schema() -> bool: + logger.info("=== ОБНОВЛЕНИЕ СХЕМЫ MULEN PAY ===") + + table_exists = await check_table_exists("mulenpay_payments") + if not table_exists: + logger.warning("⚠️ Таблица mulenpay_payments отсутствует — создаём заново") + return await create_mulenpay_payments_table() + + try: + column_exists = await check_column_exists("mulenpay_payments", "mulen_payment_id") + index_exists = await check_index_exists("mulenpay_payments", "idx_mulenpay_payment_id") + + async with engine.begin() as conn: + db_type = await get_database_type() + + if not column_exists: + if db_type == "sqlite": + alter_sql = "ALTER TABLE mulenpay_payments ADD COLUMN mulen_payment_id INTEGER NULL" + elif db_type == "postgresql": + alter_sql = "ALTER TABLE mulenpay_payments ADD COLUMN mulen_payment_id INTEGER NULL" + elif db_type == "mysql": + alter_sql = "ALTER TABLE mulenpay_payments ADD COLUMN mulen_payment_id INT NULL" + else: + logger.error( + "Неподдерживаемый тип БД для добавления mulen_payment_id в mulenpay_payments: %s", + db_type, + ) + return False + + await conn.execute(text(alter_sql)) + logger.info("✅ Добавлена колонка mulenpay_payments.mulen_payment_id") + else: + logger.info("ℹ️ Колонка mulenpay_payments.mulen_payment_id уже существует") + + if not index_exists: + if db_type == "sqlite": + create_index_sql = ( + "CREATE INDEX IF NOT EXISTS idx_mulenpay_payment_id " + "ON mulenpay_payments(mulen_payment_id)" + ) + elif db_type == "postgresql": + create_index_sql = ( + "CREATE INDEX IF NOT EXISTS idx_mulenpay_payment_id " + "ON mulenpay_payments(mulen_payment_id)" + ) + elif db_type == "mysql": + create_index_sql = ( + "CREATE INDEX idx_mulenpay_payment_id " + "ON mulenpay_payments(mulen_payment_id)" + ) + else: + logger.error( + "Неподдерживаемый тип БД для создания индекса mulenpay_payment_id: %s", + db_type, + ) + return False + + await conn.execute(text(create_index_sql)) + logger.info("✅ Создан индекс idx_mulenpay_payment_id") + else: + logger.info("ℹ️ Индекс idx_mulenpay_payment_id уже существует") + + return True + + except Exception as e: + logger.error(f"Ошибка обновления схемы mulenpay_payments: {e}") + return False + + async def create_pal24_payments_table(): table_exists = await check_table_exists('pal24_payments') if table_exists: @@ -1655,6 +1724,12 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей Mulen Pay payments") + mulenpay_schema_ok = await ensure_mulenpay_payment_schema() + if mulenpay_schema_ok: + logger.info("✅ Схема Mulen Pay payments актуальна") + else: + logger.warning("⚠️ Не удалось обновить схему Mulen Pay payments") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PAL24 ===") pal24_created = await create_pal24_payments_table() if pal24_created: From 6812ff84b95e2e24c497403451d9ecbcfbed7055 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 21:08:57 +0300 Subject: [PATCH 065/146] Ensure mulenpay schema includes paid_at column --- app/database/universal_migration.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index c24503d8..eaa9d068 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -387,6 +387,7 @@ async def ensure_mulenpay_payment_schema() -> bool: try: column_exists = await check_column_exists("mulenpay_payments", "mulen_payment_id") + paid_at_column_exists = await check_column_exists("mulenpay_payments", "paid_at") index_exists = await check_index_exists("mulenpay_payments", "idx_mulenpay_payment_id") async with engine.begin() as conn: @@ -411,6 +412,25 @@ async def ensure_mulenpay_payment_schema() -> bool: else: logger.info("ℹ️ Колонка mulenpay_payments.mulen_payment_id уже существует") + if not paid_at_column_exists: + if db_type == "sqlite": + alter_paid_at_sql = "ALTER TABLE mulenpay_payments ADD COLUMN paid_at DATETIME NULL" + elif db_type == "postgresql": + alter_paid_at_sql = "ALTER TABLE mulenpay_payments ADD COLUMN paid_at TIMESTAMP NULL" + elif db_type == "mysql": + alter_paid_at_sql = "ALTER TABLE mulenpay_payments ADD COLUMN paid_at DATETIME NULL" + else: + logger.error( + "Неподдерживаемый тип БД для добавления paid_at в mulenpay_payments: %s", + db_type, + ) + return False + + await conn.execute(text(alter_paid_at_sql)) + logger.info("✅ Добавлена колонка mulenpay_payments.paid_at") + else: + logger.info("ℹ️ Колонка mulenpay_payments.paid_at уже существует") + if not index_exists: if db_type == "sqlite": create_index_sql = ( From fe1b0fcac36e8ba9e0773341758d532017b5199e Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 21:40:22 +0300 Subject: [PATCH 066/146] Handle long MulenPay status messages --- app/handlers/balance.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/handlers/balance.py b/app/handlers/balance.py index d27c5a5e..483d7c72 100644 --- a/app/handlers/balance.py +++ b/app/handlers/balance.py @@ -1182,7 +1182,13 @@ async def check_mulenpay_payment_status( f"\n❌ Платеж не был завершен. Попробуйте создать новый платеж или обратитесь в {settings.get_support_contact_display()}" ) - await callback.answer("".join(message_lines), show_alert=True) + message_text = "".join(message_lines) + + if len(message_text) > 190: + await callback.message.answer(message_text) + await callback.answer("ℹ️ Статус платежа отправлен в чат", show_alert=True) + else: + await callback.answer(message_text, show_alert=True) except Exception as e: logger.error(f"Ошибка проверки статуса MulenPay: {e}") From 5f8287f389d1515e17eace4339fdd73272be52d2 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 21:51:19 +0300 Subject: [PATCH 067/146] Add configurable MulenPay payment limits --- .env.example | 2 ++ README.md | 2 ++ app/config.py | 2 ++ app/handlers/balance.py | 20 ++++++++++++-------- app/services/payment_service.py | 16 ++++++++++++++++ 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 8412d058..d7493059 100644 --- a/.env.example +++ b/.env.example @@ -250,6 +250,8 @@ MULENPAY_LANGUAGE=ru MULENPAY_VAT_CODE=0 MULENPAY_PAYMENT_SUBJECT=4 MULENPAY_PAYMENT_MODE=4 +MULENPAY_MIN_AMOUNT_KOPEKS=10000 +MULENPAY_MAX_AMOUNT_KOPEKS=10000000 # ===== ИНТЕРФЕЙС И UX ===== diff --git a/README.md b/README.md index 7f65c08a..86b0f6dd 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,8 @@ MULENPAY_LANGUAGE=ru MULENPAY_VAT_CODE=0 MULENPAY_PAYMENT_SUBJECT=4 MULENPAY_PAYMENT_MODE=4 +MULENPAY_MIN_AMOUNT_KOPEKS=10000 +MULENPAY_MAX_AMOUNT_KOPEKS=10000000 # PAYPALYCH / PAL24 PAL24_ENABLED=false diff --git a/app/config.py b/app/config.py index 063106ce..a5bee373 100644 --- a/app/config.py +++ b/app/config.py @@ -192,6 +192,8 @@ class Settings(BaseSettings): MULENPAY_VAT_CODE: int = 0 MULENPAY_PAYMENT_SUBJECT: int = 4 MULENPAY_PAYMENT_MODE: int = 4 + MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000 + MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000 PAL24_ENABLED: bool = False PAL24_API_TOKEN: Optional[str] = None diff --git a/app/handlers/balance.py b/app/handlers/balance.py index d27c5a5e..80f68932 100644 --- a/app/handlers/balance.py +++ b/app/handlers/balance.py @@ -860,16 +860,20 @@ async def process_mulenpay_payment_amount( await message.answer("❌ Оплата через Mulen Pay временно недоступна") return + if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS: + await message.answer( + f"Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}" + ) + return + + if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS: + await message.answer( + f"Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}" + ) + return + amount_rubles = amount_kopeks / 100 - if amount_rubles < 100: - await message.answer("Минимальная сумма пополнения: 100 ₽") - return - - if amount_rubles > 100000: - await message.answer("Максимальная сумма пополнения: 100,000 ₽") - return - try: payment_service = PaymentService(message.bot) payment_result = await payment_service.create_mulenpay_payment( diff --git a/app/services/payment_service.py b/app/services/payment_service.py index a761ef5c..63d07597 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -722,6 +722,22 @@ class PaymentService: logger.error("MulenPay сервис не инициализирован") return None + if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS: + logger.warning( + "Сумма MulenPay меньше минимальной: %s < %s", + amount_kopeks, + settings.MULENPAY_MIN_AMOUNT_KOPEKS, + ) + return None + + if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS: + logger.warning( + "Сумма MulenPay больше максимальной: %s > %s", + amount_kopeks, + settings.MULENPAY_MAX_AMOUNT_KOPEKS, + ) + return None + try: payment_uuid = f"mulen_{user_id}_{uuid.uuid4().hex}" amount_rubles = amount_kopeks / 100 From e780e3731dfb0a53f032eabf678df16c69112e87 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 22:26:14 +0300 Subject: [PATCH 068/146] Skip trial welcome message for campaign newcomers --- app/handlers/start.py | 44 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/app/handlers/start.py b/app/handlers/start.py index 4a0175b0..801cd606 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -591,7 +591,16 @@ async def complete_registration_from_callback( data = await state.get_data() or {} language = data.get('language', DEFAULT_LANGUAGE) texts = get_texts(language) - + + campaign_id = data.get('campaign_id') + is_new_user_registration = ( + existing_user is None + or ( + existing_user + and existing_user.status == UserStatus.DELETED.value + ) + ) + referrer_id = data.get('referrer_id') if not referrer_id and data.get('referral_code'): referrer = await get_user_by_referral_code(db, data['referral_code']) @@ -689,7 +698,16 @@ async def complete_registration_from_callback( from app.database.crud.welcome_text import get_welcome_text_for_user offer_text = await get_welcome_text_for_user(db, callback.from_user) - if offer_text: + skip_welcome_offer = bool(campaign_id) and is_new_user_registration + + if skip_welcome_offer: + logger.info( + "ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s", + user.telegram_id, + campaign_id, + ) + + if offer_text and not skip_welcome_offer: try: await callback.message.answer( offer_text, @@ -797,7 +815,16 @@ async def complete_registration( data = await state.get_data() or {} language = data.get('language', DEFAULT_LANGUAGE) texts = get_texts(language) - + + campaign_id = data.get('campaign_id') + is_new_user_registration = ( + existing_user is None + or ( + existing_user + and existing_user.status == UserStatus.DELETED.value + ) + ) + referrer_id = data.get('referrer_id') if not referrer_id and data.get('referral_code'): referrer = await get_user_by_referral_code(db, data['referral_code']) @@ -895,7 +922,16 @@ async def complete_registration( from app.database.crud.welcome_text import get_welcome_text_for_user offer_text = await get_welcome_text_for_user(db, message.from_user) - if offer_text: + skip_welcome_offer = bool(campaign_id) and is_new_user_registration + + if skip_welcome_offer: + logger.info( + "ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s", + user.telegram_id, + campaign_id, + ) + + if offer_text and not skip_welcome_offer: try: await message.answer( offer_text, From c147a6c010c93589373e9e3d74c6080ad4944405 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 22:53:21 +0300 Subject: [PATCH 069/146] Update docker-hub.yml --- .github/workflows/docker-hub.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index aa4c23c1..1a6e5e5b 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -36,15 +36,15 @@ jobs: TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🏷️ Собираем релизную версию: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.3.8-$(git rev-parse --short HEAD)" + VERSION="v2.3.9-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🚀 Собираем версию из main: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.3.8-dev-$(git rev-parse --short HEAD)" + VERSION="v2.3.9-dev-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🧪 Собираем dev версию: $VERSION" else - VERSION="v2.3.8-pr-$(git rev-parse --short HEAD)" + VERSION="v2.3.9-pr-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)" echo "🔀 Собираем PR версию: $VERSION" fi From 80c9857475e6e86e0e4580a6ac950ee0cbe729ef Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 22:53:40 +0300 Subject: [PATCH 070/146] Update docker-registry.yml --- .github/workflows/docker-registry.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-registry.yml b/.github/workflows/docker-registry.yml index 12796fc9..907ea3fd 100644 --- a/.github/workflows/docker-registry.yml +++ b/.github/workflows/docker-registry.yml @@ -49,13 +49,13 @@ jobs: VERSION=${GITHUB_REF#refs/tags/} echo "🏷️ Building release version: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.3.8-$(git rev-parse --short HEAD)" + VERSION="v2.3.9-$(git rev-parse --short HEAD)" echo "🚀 Building main version: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.3.8-dev-$(git rev-parse --short HEAD)" + VERSION="v2.3.9-dev-$(git rev-parse --short HEAD)" echo "🧪 Building dev version: $VERSION" else - VERSION="v2.3.8-pr-$(git rev-parse --short HEAD)" + VERSION="v2.3.9-pr-$(git rev-parse --short HEAD)" echo "🔀 Building PR version: $VERSION" fi echo "version=$VERSION" >> $GITHUB_OUTPUT From 34c19ff0419a8d25a0b1015690f0bd532f9bdc0a Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 22:53:55 +0300 Subject: [PATCH 071/146] Update Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index fde6f796..0fb39c32 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \ FROM python:3.13-slim -ARG VERSION="v2.3.8" +ARG VERSION="v2.3.9" ARG BUILD_DATE ARG VCS_REF From ae3c34c2fe541e04b5e969b7dce8bed5fd92a6c5 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 03:59:03 +0300 Subject: [PATCH 072/146] chore: tidy migration imports --- .env.example | 9 +- README.md | 7 + app/config.py | 31 ++ app/database/crud/subscription.py | 6 +- app/database/models.py | 3 +- app/external/remnawave_api.py | 6 +- app/handlers/subscription.py | 298 +++++++++++++++++- app/keyboards/inline.py | 19 ++ app/localization/locales/en.json | 11 + app/localization/locales/ru.json | 11 + app/services/remnawave_service.py | 25 +- app/services/subscription_service.py | 8 +- locales/en.json | 11 + locales/ru.json | 11 + ...1_add_happ_crypto_link_to_subscriptions.py | 42 +++ 15 files changed, 483 insertions(+), 15 deletions(-) create mode 100644 migrations/alembic/versions/b7a0e4031581_add_happ_crypto_link_to_subscriptions.py diff --git a/.env.example b/.env.example index 41eb0876..3e8d89b6 100644 --- a/.env.example +++ b/.env.example @@ -279,12 +279,19 @@ HIDE_SUBSCRIPTION_LINK=false # guide - открывает гайд подключения (режим 1) # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) -# link - Открывает ссылку напрямую в браузере (режим 4) +# link - открывает ссылку напрямую в браузере (режим 4) +# happ_cryptolink - открывает ссылку Happ из поля cryptoLink (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +# Включить кнопку скачивания Happ и ссылки на магазины +HAPP_DOWNLOAD_BUTTON_ENABLED=false +HAPP_DOWNLOAD_IOS_URL= +HAPP_DOWNLOAD_ANDROID_URL= +HAPP_DOWNLOAD_PC_URL= + # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода diff --git a/README.md b/README.md index 86b0f6dd..f1900780 100644 --- a/README.md +++ b/README.md @@ -521,11 +521,18 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) +# happ_cryptolink - открывает ссылку Happ из поля cryptoLink (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +# Включить кнопку скачивания Happ и ссылки на магазины +HAPP_DOWNLOAD_BUTTON_ENABLED=false +HAPP_DOWNLOAD_IOS_URL= +HAPP_DOWNLOAD_ANDROID_URL= +HAPP_DOWNLOAD_PC_URL= + # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода diff --git a/app/config.py b/app/config.py index a5bee373..c987ec50 100644 --- a/app/config.py +++ b/app/config.py @@ -209,6 +209,10 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" + HAPP_DOWNLOAD_BUTTON_ENABLED: bool = False + HAPP_DOWNLOAD_IOS_URL: Optional[str] = None + HAPP_DOWNLOAD_ANDROID_URL: Optional[str] = None + HAPP_DOWNLOAD_PC_URL: Optional[str] = None HIDE_SUBSCRIPTION_LINK: bool = False ENABLE_LOGO_MODE: bool = True LOGO_FILE: str = "vpn_logo.png" @@ -896,6 +900,33 @@ class Settings(BaseSettings): def is_server_status_enabled(self) -> bool: return self.get_server_status_mode() != "disabled" + def is_happ_download_button_enabled(self) -> bool: + return ( + self.HAPP_DOWNLOAD_BUTTON_ENABLED + and any( + link + for link in ( + self.HAPP_DOWNLOAD_IOS_URL, + self.HAPP_DOWNLOAD_ANDROID_URL, + self.HAPP_DOWNLOAD_PC_URL, + ) + if link + ) + ) + + def get_happ_download_link(self, platform: str) -> Optional[str]: + platform = (platform or "").lower() + mapping = { + "ios": self.HAPP_DOWNLOAD_IOS_URL, + "android": self.HAPP_DOWNLOAD_ANDROID_URL, + "pc": self.HAPP_DOWNLOAD_PC_URL, + } + link = mapping.get(platform) + if link: + stripped = link.strip() + return stripped or None + return None + def get_server_status_external_url(self) -> Optional[str]: url = (self.SERVER_STATUS_EXTERNAL_URL or "").strip() return url or None diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 051c2369..40300c42 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -965,7 +965,8 @@ async def create_subscription( device_limit: int = 1, connected_squads: list = None, remnawave_short_uuid: str = None, - subscription_url: str = "" + subscription_url: str = "", + happ_crypto_link: Optional[str] = None, ) -> Subscription: if end_date is None: @@ -984,7 +985,8 @@ async def create_subscription( device_limit=device_limit, connected_squads=connected_squads, remnawave_short_uuid=remnawave_short_uuid, - subscription_url=subscription_url + subscription_url=subscription_url, + happ_crypto_link=happ_crypto_link, ) db.add(subscription) diff --git a/app/database/models.py b/app/database/models.py index 0a19fe07..36ad19db 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -435,7 +435,8 @@ class Subscription(Base): traffic_used_gb = Column(Float, default=0.0) subscription_url = Column(String, nullable=True) - + happ_crypto_link = Column(String, nullable=True) + device_limit = Column(Integer, default=1) connected_squads = Column(JSON, default=list) diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index ec553efb..ca80b3f2 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -58,6 +58,7 @@ class RemnaWaveUser: ss_password: Optional[str] = None first_connected_at: Optional[datetime] = None last_triggered_threshold: int = 0 + happ: Optional[Dict[str, Any]] = None @dataclass @@ -91,7 +92,7 @@ class SubscriptionInfo: links: List[str] ss_conf_links: Dict[str, str] subscription_url: str - happ: Optional[Dict[str, str]] + happ: Optional[Dict[str, Any]] class RemnaWaveAPIError(Exception): @@ -612,7 +613,8 @@ class RemnaWaveAPI: vless_uuid=user_data.get('vlessUuid'), ss_password=user_data.get('ssPassword'), first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')), - last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0) + last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0), + happ=user_data.get('happ'), ) def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]: diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 4c0b14a2..ea96e031 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -877,6 +877,41 @@ async def activate_trial( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) + elif connect_mode == "happ_cryptolink": + happ_link = getattr(subscription, 'happ_crypto_link', None) + buttons = [] + + if happ_link: + buttons.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=happ_link, + ) + ]) + else: + buttons.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect", + ) + ]) + + if settings.is_happ_download_button_enabled(): + buttons.append([ + InlineKeyboardButton( + text=texts.t("DOWNLOAD_HAPP_APP_BUTTON", "📲 Скачать приложение Happ"), + callback_data="download_happ_app", + ) + ]) + + buttons.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu", + ) + ]) + + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) elif connect_mode == "link": connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)], @@ -3323,6 +3358,41 @@ async def confirm_purchase( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) + elif connect_mode == "happ_cryptolink": + happ_link = getattr(subscription, 'happ_crypto_link', None) + buttons = [] + + if happ_link: + buttons.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=happ_link, + ) + ]) + else: + buttons.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect", + ) + ]) + + if settings.is_happ_download_button_enabled(): + buttons.append([ + InlineKeyboardButton( + text=texts.t("DOWNLOAD_HAPP_APP_BUTTON", "📲 Скачать приложение Happ"), + callback_data="download_happ_app", + ) + ]) + + buttons.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu", + ) + ]) + + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) elif connect_mode == "link": connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)], @@ -4016,7 +4086,7 @@ async def handle_connect_subscription( texts = get_texts(db_user.language) subscription = db_user.subscription - if not subscription or not subscription.subscription_url: + if not subscription: await callback.answer( texts.t( "SUBSCRIPTION_NO_ACTIVE_LINK", @@ -4028,6 +4098,26 @@ async def handle_connect_subscription( connect_mode = settings.CONNECT_BUTTON_MODE + if connect_mode != "happ_cryptolink" and not subscription.subscription_url: + await callback.answer( + texts.t( + "SUBSCRIPTION_NO_ACTIVE_LINK", + "⚠ У вас нет активной подписки или ссылка еще генерируется", + ), + show_alert=True, + ) + return + + if connect_mode == "happ_cryptolink" and not getattr(subscription, 'happ_crypto_link', None): + await callback.answer( + texts.t( + "SUBSCRIPTION_HAPP_CRYPTO_LINK_MISSING", + "⚠ Ссылка Happ пока недоступна. Попробуйте позже.", + ), + show_alert=True, + ) + return + if connect_mode == "miniapp_subscription": keyboard = InlineKeyboardMarkup(inline_keyboard=[ [ @@ -4086,6 +4176,53 @@ async def handle_connect_subscription( parse_mode="HTML" ) + elif connect_mode == "happ_cryptolink": + happ_link = getattr(subscription, 'happ_crypto_link', None) + + if not happ_link: + await callback.answer( + texts.t( + "SUBSCRIPTION_HAPP_CRYPTO_LINK_MISSING", + "⚠ Ссылка Happ пока недоступна. Попробуйте позже.", + ), + show_alert=True, + ) + return + + buttons = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=happ_link, + ) + ] + ] + + if settings.is_happ_download_button_enabled(): + buttons.append([ + InlineKeyboardButton( + text=texts.t("DOWNLOAD_HAPP_APP_BUTTON", "📲 Скачать приложение Happ"), + callback_data="download_happ_app", + ) + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) + + await callback.message.edit_text( + texts.t( + "SUBSCRIPTION_CONNECT_HAPP_MESSAGE", + """📱 Подключить подписку Happ + +🚀 Нажмите кнопку ниже, чтобы открыть ссылку Happ для подключения подписки.""", + ), + reply_markup=keyboard, + parse_mode="HTML", + ) + elif connect_mode == "link": keyboard = InlineKeyboardMarkup(inline_keyboard=[ [ @@ -4126,7 +4263,133 @@ async def handle_connect_subscription( reply_markup=get_device_selection_keyboard(db_user.language), parse_mode="HTML" ) - + + await callback.answer() + + +async def handle_download_happ_app( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + if not settings.is_happ_download_button_enabled(): + await callback.answer( + texts.t( + "DOWNLOAD_HAPP_APP_NOT_AVAILABLE", + "⚠️ Загрузка Happ временно недоступна.", + ), + show_alert=True, + ) + return + + options = [] + for platform in ("ios", "android", "pc"): + link = settings.get_happ_download_link(platform) + if not link: + continue + + options.append([ + InlineKeyboardButton( + text=texts.t( + f"DOWNLOAD_HAPP_DEVICE_{platform.upper()}", + { + "ios": "📱 iOS", + "android": "🤖 Android", + "pc": "💻 ПК", + }[platform], + ), + callback_data=f"download_happ_app_{platform}", + ) + ]) + + if not options: + await callback.answer( + texts.t( + "DOWNLOAD_HAPP_APP_NOT_AVAILABLE", + "⚠️ Загрузка Happ временно недоступна.", + ), + show_alert=True, + ) + return + + options.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="subscription_connect") + ]) + options.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_SUBSCRIPTION", "⬅️ К подписке"), + callback_data="menu_subscription", + ) + ]) + + await callback.message.edit_text( + texts.t( + "DOWNLOAD_HAPP_APP_PROMPT", + """📲 Скачать Happ + +Выберите устройство, чтобы получить ссылку на загрузку:""", + ), + reply_markup=InlineKeyboardMarkup(inline_keyboard=options), + parse_mode="HTML", + ) + await callback.answer() + + +async def handle_download_happ_device( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + _, _, platform = callback.data.partition("download_happ_app_") + platform = platform or "" + texts = get_texts(db_user.language) + + link = settings.get_happ_download_link(platform) + if not link: + await callback.answer( + texts.t( + "DOWNLOAD_HAPP_APP_LINK_MISSING", + "⚠️ Ссылка для выбранного устройства недоступна.", + ), + show_alert=True, + ) + return + + device_name = get_happ_device_name(platform, db_user.language) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t( + "DOWNLOAD_HAPP_OPEN_STORE", + "📥 Открыть в магазине", + ), + url=link, + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data="download_happ_app")], + [ + InlineKeyboardButton( + text=texts.t("BACK_TO_SUBSCRIPTION", "⬅️ К подписке"), + callback_data="menu_subscription", + ) + ], + ] + ) + + await callback.message.edit_text( + texts.t( + "DOWNLOAD_HAPP_APP_LINK_MESSAGE", + """📥 Скачать Happ для {device_name} + +Нажмите кнопку ниже, чтобы открыть приложение в магазине.""", + ).format(device_name=device_name), + reply_markup=keyboard, + parse_mode="HTML", + ) await callback.answer() @@ -4462,7 +4725,7 @@ def load_app_config() -> Dict[str, Any]: def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str, Any]]: config = load_app_config() - + device_mapping = { 'ios': 'ios', 'android': 'android', @@ -4475,6 +4738,25 @@ def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str return config.get(config_key, []) +def get_happ_device_name(platform: str, language: str = "ru") -> str: + platform = (platform or "").lower() + + if language == "en": + names = { + "ios": "iOS", + "android": "Android", + "pc": "PC", + } + else: + names = { + "ios": "iOS", + "android": "Android", + "pc": "ПК", + } + + return names.get(platform, platform.upper() or platform) + + def get_device_name(device_type: str, language: str = "ru") -> str: if language == "en": names = { @@ -5125,6 +5407,16 @@ def register_handlers(dp: Dispatcher): F.data == "open_subscription_link" ) + dp.callback_query.register( + handle_download_happ_app, + F.data == "download_happ_app", + ) + + dp.callback_query.register( + handle_download_happ_device, + F.data.startswith("download_happ_app_"), + ) + dp.callback_query.register( handle_subscription_settings, F.data == "subscription_settings" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index c367b562..41a455e5 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -112,6 +112,25 @@ def get_main_menu_keyboard( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) ) ]) + elif connect_mode == "happ_cryptolink": + happ_link = getattr(subscription, "happ_crypto_link", None) + if happ_link: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=happ_link, + ) + ]) + else: + keyboard.append([_fallback_connect_button()]) + + if settings.is_happ_download_button_enabled(): + keyboard.append([ + InlineKeyboardButton( + text=texts.t("DOWNLOAD_HAPP_APP_BUTTON", "📲 Скачать приложение Happ"), + callback_data="download_happ_app", + ) + ]) elif connect_mode == "link": if subscription_url: keyboard.append([ diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 8fba5d67..f489e5b2 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -310,9 +310,11 @@ "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", + "SUBSCRIPTION_CONNECT_HAPP_MESSAGE": "📱 Connect Happ subscription\n\n🚀 Click the button below to open the Happ link and connect your subscription.", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", + "SUBSCRIPTION_HAPP_CRYPTO_LINK_MISSING": "⚠ Happ link is temporarily unavailable. Please try again later.", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", @@ -336,6 +338,15 @@ "SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option", "SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link", "SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.", + "DOWNLOAD_HAPP_APP_BUTTON": "📲 Download Happ app", + "DOWNLOAD_HAPP_APP_PROMPT": "📲 Download Happ\n\nChoose your device to get the store link:", + "DOWNLOAD_HAPP_APP_NOT_AVAILABLE": "⚠️ Happ download is temporarily unavailable.", + "DOWNLOAD_HAPP_APP_LINK_MISSING": "⚠️ The download link for the selected device is unavailable.", + "DOWNLOAD_HAPP_DEVICE_IOS": "📱 iOS", + "DOWNLOAD_HAPP_DEVICE_ANDROID": "🤖 Android", + "DOWNLOAD_HAPP_DEVICE_PC": "💻 PC", + "DOWNLOAD_HAPP_OPEN_STORE": "📥 Open in store", + "DOWNLOAD_HAPP_APP_LINK_MESSAGE": "📥 Download Happ for {device_name}\n\nTap the button below to open the app in the store.", "REFERRAL_PROGRAM_TITLE": "👥 Referral program", "REFERRAL_STATS_HEADER": "📊 Your statistics:", "REFERRAL_STATS_INVITED": "• Invited users: {count}", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index d51eee5b..f53de4c9 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -312,9 +312,11 @@ "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", + "SUBSCRIPTION_CONNECT_HAPP_MESSAGE": "📱 Подключить подписку Happ\n\n🚀 Нажмите кнопку ниже, чтобы открыть ссылку Happ для подключения подписки.", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", + "SUBSCRIPTION_HAPP_CRYPTO_LINK_MISSING": "⚠ Ссылка Happ пока недоступна. Попробуйте позже.", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", @@ -338,6 +340,15 @@ "SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"", "SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку", "SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", + "DOWNLOAD_HAPP_APP_BUTTON": "📲 Скачать приложение Happ", + "DOWNLOAD_HAPP_APP_PROMPT": "📲 Скачать Happ\n\nВыберите устройство, чтобы получить ссылку на загрузку:", + "DOWNLOAD_HAPP_APP_NOT_AVAILABLE": "⚠️ Загрузка Happ временно недоступна.", + "DOWNLOAD_HAPP_APP_LINK_MISSING": "⚠️ Ссылка для выбранного устройства недоступна.", + "DOWNLOAD_HAPP_DEVICE_IOS": "📱 iOS", + "DOWNLOAD_HAPP_DEVICE_ANDROID": "🤖 Android", + "DOWNLOAD_HAPP_DEVICE_PC": "💻 ПК", + "DOWNLOAD_HAPP_OPEN_STORE": "📥 Открыть в магазине", + "DOWNLOAD_HAPP_APP_LINK_MESSAGE": "📥 Скачать Happ для {device_name}\n\nНажмите кнопку ниже, чтобы открыть приложение в магазине.", "REFERRAL_PROGRAM_TITLE": "👥 Реферальная программа", "REFERRAL_STATS_HEADER": "📊 Ваша статистика:", "REFERRAL_STATS_INVITED": "• Приглашено пользователей: {count}", diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index dd6ee50c..20490595 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -634,19 +634,25 @@ class RemnaWaveService: elif isinstance(squad, str): squad_uuids.append(squad) + happ_crypto_link = None + happ_data = panel_user.get('happ') + if isinstance(happ_data, dict): + happ_crypto_link = happ_data.get('cryptoLink') or None + subscription_data = { 'user_id': user.id, 'status': status.value, - 'is_trial': False, + 'is_trial': False, 'end_date': expire_at, 'traffic_limit_gb': traffic_limit_gb, 'traffic_used_gb': traffic_used_gb, 'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1, 'connected_squads': squad_uuids, 'remnawave_short_uuid': panel_user.get('shortUuid'), - 'subscription_url': panel_user.get('subscriptionUrl', '') + 'subscription_url': panel_user.get('subscriptionUrl', ''), + 'happ_crypto_link': happ_crypto_link, } - + subscription = await create_subscription(db, **subscription_data) logger.info(f"✅ Создана подписка для пользователя {user.telegram_id} до {expire_at}") @@ -667,7 +673,8 @@ class RemnaWaveService: device_limit=1, connected_squads=[], remnawave_short_uuid=panel_user.get('shortUuid'), - subscription_url=panel_user.get('subscriptionUrl', '') + subscription_url=panel_user.get('subscriptionUrl', ''), + happ_crypto_link=happ_crypto_link, ) logger.info(f"✅ Создана базовая подписка для пользователя {user.telegram_id}") except Exception as basic_error: @@ -733,7 +740,15 @@ class RemnaWaveService: panel_url = panel_user.get('subscriptionUrl', '') if not subscription.subscription_url or subscription.subscription_url != panel_url: subscription.subscription_url = panel_url - + + happ_crypto_link = None + happ_data = panel_user.get('happ') + if isinstance(happ_data, dict): + happ_crypto_link = happ_data.get('cryptoLink') or None + + if subscription.happ_crypto_link != happ_crypto_link: + subscription.happ_crypto_link = happ_crypto_link + active_squads = panel_user.get('activeInternalSquads', []) squad_uuids = [] if isinstance(active_squads, list): diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 7e25c427..37837237 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -130,7 +130,10 @@ class SubscriptionService: ) subscription.remnawave_short_uuid = updated_user.short_uuid - subscription.subscription_url = updated_user.subscription_url + subscription.subscription_url = updated_user.subscription_url + happ_data = getattr(updated_user, 'happ', None) + if isinstance(happ_data, dict): + subscription.happ_crypto_link = happ_data.get('cryptoLink') or None user.remnawave_uuid = updated_user.uuid await db.commit() @@ -190,6 +193,9 @@ class SubscriptionService: ) subscription.subscription_url = updated_user.subscription_url + happ_data = getattr(updated_user, 'happ', None) + if isinstance(happ_data, dict): + subscription.happ_crypto_link = happ_data.get('cryptoLink') or None await db.commit() status_text = "активным" if is_actually_active else "истёкшим" diff --git a/locales/en.json b/locales/en.json index bb1b7fc8..45730a1f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -391,9 +391,11 @@ "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", + "SUBSCRIPTION_CONNECT_HAPP_MESSAGE": "📱 Connect Happ subscription\n\n🚀 Click the button below to open the Happ link and connect your subscription.", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", + "SUBSCRIPTION_HAPP_CRYPTO_LINK_MISSING": "⚠ Happ link is temporarily unavailable. Please try again later.", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", @@ -417,6 +419,15 @@ "SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option", "SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link", "SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.", + "DOWNLOAD_HAPP_APP_BUTTON": "📲 Download Happ app", + "DOWNLOAD_HAPP_APP_PROMPT": "📲 Download Happ\n\nChoose your device to get the store link:", + "DOWNLOAD_HAPP_APP_NOT_AVAILABLE": "⚠️ Happ download is temporarily unavailable.", + "DOWNLOAD_HAPP_APP_LINK_MISSING": "⚠️ The download link for the selected device is unavailable.", + "DOWNLOAD_HAPP_DEVICE_IOS": "📱 iOS", + "DOWNLOAD_HAPP_DEVICE_ANDROID": "🤖 Android", + "DOWNLOAD_HAPP_DEVICE_PC": "💻 PC", + "DOWNLOAD_HAPP_OPEN_STORE": "📥 Open in store", + "DOWNLOAD_HAPP_APP_LINK_MESSAGE": "📥 Download Happ for {device_name}\n\nTap the button below to open the app in the store.", "REFERRAL_PROGRAM_TITLE": "👥 Referral program", "REFERRAL_STATS_HEADER": "📊 Your statistics:", "REFERRAL_STATS_INVITED": "• Invited users: {count}", diff --git a/locales/ru.json b/locales/ru.json index ff9fa404..69e8a37c 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -391,9 +391,11 @@ "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", + "SUBSCRIPTION_CONNECT_HAPP_MESSAGE": "📱 Подключить подписку Happ\n\n🚀 Нажмите кнопку ниже, чтобы открыть ссылку Happ для подключения подписки.", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", + "SUBSCRIPTION_HAPP_CRYPTO_LINK_MISSING": "⚠ Ссылка Happ пока недоступна. Попробуйте позже.", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", @@ -417,6 +419,15 @@ "SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"", "SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку", "SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", + "DOWNLOAD_HAPP_APP_BUTTON": "📲 Скачать приложение Happ", + "DOWNLOAD_HAPP_APP_PROMPT": "📲 Скачать Happ\n\nВыберите устройство, чтобы получить ссылку на загрузку:", + "DOWNLOAD_HAPP_APP_NOT_AVAILABLE": "⚠️ Загрузка Happ временно недоступна.", + "DOWNLOAD_HAPP_APP_LINK_MISSING": "⚠️ Ссылка для выбранного устройства недоступна.", + "DOWNLOAD_HAPP_DEVICE_IOS": "📱 iOS", + "DOWNLOAD_HAPP_DEVICE_ANDROID": "🤖 Android", + "DOWNLOAD_HAPP_DEVICE_PC": "💻 ПК", + "DOWNLOAD_HAPP_OPEN_STORE": "📥 Открыть в магазине", + "DOWNLOAD_HAPP_APP_LINK_MESSAGE": "📥 Скачать Happ для {device_name}\n\nНажмите кнопку ниже, чтобы открыть приложение в магазине.", "REFERRAL_PROGRAM_TITLE": "👥 Реферальная программа", "REFERRAL_STATS_HEADER": "📊 Ваша статистика:", "REFERRAL_STATS_INVITED": "• Приглашено пользователей: {count}", diff --git a/migrations/alembic/versions/b7a0e4031581_add_happ_crypto_link_to_subscriptions.py b/migrations/alembic/versions/b7a0e4031581_add_happ_crypto_link_to_subscriptions.py new file mode 100644 index 00000000..11ba9519 --- /dev/null +++ b/migrations/alembic/versions/b7a0e4031581_add_happ_crypto_link_to_subscriptions.py @@ -0,0 +1,42 @@ +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "b7a0e4031581" +down_revision: Union[str, None] = "5d1f1f8b2e9a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +SUBSCRIPTIONS_TABLE = "subscriptions" +HAPP_CRYPTO_LINK_COLUMN = "happ_crypto_link" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if SUBSCRIPTIONS_TABLE in inspector.get_table_names(): + columns = {column["name"] for column in inspector.get_columns(SUBSCRIPTIONS_TABLE)} + if HAPP_CRYPTO_LINK_COLUMN not in columns: + op.add_column( + SUBSCRIPTIONS_TABLE, + sa.Column(HAPP_CRYPTO_LINK_COLUMN, sa.String(), nullable=True), + ) + else: + op.add_column( + SUBSCRIPTIONS_TABLE, + sa.Column(HAPP_CRYPTO_LINK_COLUMN, sa.String(), nullable=True), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if SUBSCRIPTIONS_TABLE in inspector.get_table_names(): + columns = {column["name"] for column in inspector.get_columns(SUBSCRIPTIONS_TABLE)} + if HAPP_CRYPTO_LINK_COLUMN in columns: + op.drop_column(SUBSCRIPTIONS_TABLE, HAPP_CRYPTO_LINK_COLUMN) From 92dbf3269b7a13a5b6827580c49e28ec7331514c Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 04:08:49 +0300 Subject: [PATCH 073/146] Add happ_crypto_link column handling to universal migration --- app/database/universal_migration.py | 40 +++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index eaa9d068..83bab131 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1366,6 +1366,31 @@ async def add_ticket_sla_columns(): logger.error(f"Ошибка добавления SLA колонки в tickets: {e}") return False +async def add_happ_crypto_link_column(): + try: + column_exists = await check_column_exists('subscriptions', 'happ_crypto_link') + if column_exists: + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type in ('sqlite', 'postgresql', 'mysql'): + alter_sql = "ALTER TABLE subscriptions ADD COLUMN happ_crypto_link TEXT NULL" + else: + logger.error( + f"Неподдерживаемый тип БД для добавления subscriptions.happ_crypto_link: {db_type}" + ) + return False + + await conn.execute(text(alter_sql)) + logger.info("✅ Добавлена колонка subscriptions.happ_crypto_link") + return True + + except Exception as e: + logger.error(f"Ошибка добавления колонки happ_crypto_link в subscriptions: {e}") + return False + async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: @@ -1891,11 +1916,18 @@ async def run_universal_migration(): logger.info("✅ Таблица subscription_conversions готова") else: logger.warning("⚠️ Проблемы с таблицей subscription_conversions") - + + logger.info("=== ДОБАВЛЕНИЕ ПОЛЯ HAPP_CRYPTO_LINK В SUBSCRIPTIONS ===") + happ_crypto_link_added = await add_happ_crypto_link_column() + if happ_crypto_link_added: + logger.info("✅ Поле happ_crypto_link в subscriptions готово") + else: + logger.warning("⚠️ Проблемы с добавлением поля happ_crypto_link в subscriptions") + async with engine.begin() as conn: total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions")) unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions")) - + total_count = total_subs.fetchone()[0] unique_count = unique_users.fetchone()[0] @@ -1929,6 +1961,7 @@ async def run_universal_migration(): logger.info("✅ Таблица конверсий подписок создана") logger.info("✅ Таблица welcome_texts с полем is_enabled готова") logger.info("✅ Медиа поля в broadcast_history добавлены") + logger.info("✅ Поле happ_crypto_link в subscriptions добавлено") logger.info("✅ Дубликаты подписок исправлены") return True @@ -1949,6 +1982,7 @@ async def check_migration_status(): "broadcast_history_media_fields": False, "subscription_duplicates": False, "subscription_conversions_table": False, + "subscriptions_happ_crypto_link_column": False, "promo_groups_table": False, "server_promo_groups_table": False, "users_promo_group_column": False, @@ -1963,6 +1997,7 @@ async def check_migration_status(): status["user_messages_table"] = await check_table_exists('user_messages') status["welcome_texts_table"] = await check_table_exists('welcome_texts') status["subscription_conversions_table"] = await check_table_exists('subscription_conversions') + status["subscriptions_happ_crypto_link_column"] = await check_column_exists('subscriptions', 'happ_crypto_link') status["promo_groups_table"] = await check_table_exists('promo_groups') status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups') @@ -2001,6 +2036,7 @@ async def check_migration_status(): "broadcast_history_media_fields": "Медиа поля в broadcast_history", "subscription_conversions_table": "Таблица конверсий подписок", "subscription_duplicates": "Отсутствие дубликатов подписок", + "subscriptions_happ_crypto_link_column": "Поле happ_crypto_link в subscriptions", "promo_groups_table": "Таблица промо-групп", "server_promo_groups_table": "Связи серверов и промогрупп", "users_promo_group_column": "Колонка promo_group_id у пользователей", From 1a060759f909300399218d86f50a7eeb78b03206 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 04:12:24 +0300 Subject: [PATCH 074/146] Revert "Add happ_crypto_link column handling to universal migration" --- app/database/universal_migration.py | 40 ++--------------------------- 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 83bab131..eaa9d068 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1366,31 +1366,6 @@ async def add_ticket_sla_columns(): logger.error(f"Ошибка добавления SLA колонки в tickets: {e}") return False -async def add_happ_crypto_link_column(): - try: - column_exists = await check_column_exists('subscriptions', 'happ_crypto_link') - if column_exists: - return True - - async with engine.begin() as conn: - db_type = await get_database_type() - - if db_type in ('sqlite', 'postgresql', 'mysql'): - alter_sql = "ALTER TABLE subscriptions ADD COLUMN happ_crypto_link TEXT NULL" - else: - logger.error( - f"Неподдерживаемый тип БД для добавления subscriptions.happ_crypto_link: {db_type}" - ) - return False - - await conn.execute(text(alter_sql)) - logger.info("✅ Добавлена колонка subscriptions.happ_crypto_link") - return True - - except Exception as e: - logger.error(f"Ошибка добавления колонки happ_crypto_link в subscriptions: {e}") - return False - async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: @@ -1916,18 +1891,11 @@ async def run_universal_migration(): logger.info("✅ Таблица subscription_conversions готова") else: logger.warning("⚠️ Проблемы с таблицей subscription_conversions") - - logger.info("=== ДОБАВЛЕНИЕ ПОЛЯ HAPP_CRYPTO_LINK В SUBSCRIPTIONS ===") - happ_crypto_link_added = await add_happ_crypto_link_column() - if happ_crypto_link_added: - logger.info("✅ Поле happ_crypto_link в subscriptions готово") - else: - logger.warning("⚠️ Проблемы с добавлением поля happ_crypto_link в subscriptions") - + async with engine.begin() as conn: total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions")) unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions")) - + total_count = total_subs.fetchone()[0] unique_count = unique_users.fetchone()[0] @@ -1961,7 +1929,6 @@ async def run_universal_migration(): logger.info("✅ Таблица конверсий подписок создана") logger.info("✅ Таблица welcome_texts с полем is_enabled готова") logger.info("✅ Медиа поля в broadcast_history добавлены") - logger.info("✅ Поле happ_crypto_link в subscriptions добавлено") logger.info("✅ Дубликаты подписок исправлены") return True @@ -1982,7 +1949,6 @@ async def check_migration_status(): "broadcast_history_media_fields": False, "subscription_duplicates": False, "subscription_conversions_table": False, - "subscriptions_happ_crypto_link_column": False, "promo_groups_table": False, "server_promo_groups_table": False, "users_promo_group_column": False, @@ -1997,7 +1963,6 @@ async def check_migration_status(): status["user_messages_table"] = await check_table_exists('user_messages') status["welcome_texts_table"] = await check_table_exists('welcome_texts') status["subscription_conversions_table"] = await check_table_exists('subscription_conversions') - status["subscriptions_happ_crypto_link_column"] = await check_column_exists('subscriptions', 'happ_crypto_link') status["promo_groups_table"] = await check_table_exists('promo_groups') status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups') @@ -2036,7 +2001,6 @@ async def check_migration_status(): "broadcast_history_media_fields": "Медиа поля в broadcast_history", "subscription_conversions_table": "Таблица конверсий подписок", "subscription_duplicates": "Отсутствие дубликатов подписок", - "subscriptions_happ_crypto_link_column": "Поле happ_crypto_link в subscriptions", "promo_groups_table": "Таблица промо-групп", "server_promo_groups_table": "Связи серверов и промогрупп", "users_promo_group_column": "Колонка promo_group_id у пользователей", From ec6b6e6b09c4ed9330443e2216edeed35cf29fd7 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 04:12:42 +0300 Subject: [PATCH 075/146] Revert "feat: add Happ crypto link mode and download flow" --- .env.example | 9 +- README.md | 7 - app/config.py | 31 -- app/database/crud/subscription.py | 6 +- app/database/models.py | 3 +- app/external/remnawave_api.py | 6 +- app/handlers/subscription.py | 298 +----------------- app/keyboards/inline.py | 19 -- app/localization/locales/en.json | 11 - app/localization/locales/ru.json | 11 - app/services/remnawave_service.py | 25 +- app/services/subscription_service.py | 8 +- locales/en.json | 11 - locales/ru.json | 11 - ...1_add_happ_crypto_link_to_subscriptions.py | 42 --- 15 files changed, 15 insertions(+), 483 deletions(-) delete mode 100644 migrations/alembic/versions/b7a0e4031581_add_happ_crypto_link_to_subscriptions.py diff --git a/.env.example b/.env.example index 3e8d89b6..41eb0876 100644 --- a/.env.example +++ b/.env.example @@ -279,19 +279,12 @@ HIDE_SUBSCRIPTION_LINK=false # guide - открывает гайд подключения (режим 1) # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) -# link - открывает ссылку напрямую в браузере (режим 4) -# happ_cryptolink - открывает ссылку Happ из поля cryptoLink (режим 5) +# link - Открывает ссылку напрямую в браузере (режим 4) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= -# Включить кнопку скачивания Happ и ссылки на магазины -HAPP_DOWNLOAD_BUTTON_ENABLED=false -HAPP_DOWNLOAD_IOS_URL= -HAPP_DOWNLOAD_ANDROID_URL= -HAPP_DOWNLOAD_PC_URL= - # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода diff --git a/README.md b/README.md index f1900780..86b0f6dd 100644 --- a/README.md +++ b/README.md @@ -521,18 +521,11 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) -# happ_cryptolink - открывает ссылку Happ из поля cryptoLink (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= -# Включить кнопку скачивания Happ и ссылки на магазины -HAPP_DOWNLOAD_BUTTON_ENABLED=false -HAPP_DOWNLOAD_IOS_URL= -HAPP_DOWNLOAD_ANDROID_URL= -HAPP_DOWNLOAD_PC_URL= - # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода diff --git a/app/config.py b/app/config.py index c987ec50..a5bee373 100644 --- a/app/config.py +++ b/app/config.py @@ -209,10 +209,6 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" - HAPP_DOWNLOAD_BUTTON_ENABLED: bool = False - HAPP_DOWNLOAD_IOS_URL: Optional[str] = None - HAPP_DOWNLOAD_ANDROID_URL: Optional[str] = None - HAPP_DOWNLOAD_PC_URL: Optional[str] = None HIDE_SUBSCRIPTION_LINK: bool = False ENABLE_LOGO_MODE: bool = True LOGO_FILE: str = "vpn_logo.png" @@ -900,33 +896,6 @@ class Settings(BaseSettings): def is_server_status_enabled(self) -> bool: return self.get_server_status_mode() != "disabled" - def is_happ_download_button_enabled(self) -> bool: - return ( - self.HAPP_DOWNLOAD_BUTTON_ENABLED - and any( - link - for link in ( - self.HAPP_DOWNLOAD_IOS_URL, - self.HAPP_DOWNLOAD_ANDROID_URL, - self.HAPP_DOWNLOAD_PC_URL, - ) - if link - ) - ) - - def get_happ_download_link(self, platform: str) -> Optional[str]: - platform = (platform or "").lower() - mapping = { - "ios": self.HAPP_DOWNLOAD_IOS_URL, - "android": self.HAPP_DOWNLOAD_ANDROID_URL, - "pc": self.HAPP_DOWNLOAD_PC_URL, - } - link = mapping.get(platform) - if link: - stripped = link.strip() - return stripped or None - return None - def get_server_status_external_url(self) -> Optional[str]: url = (self.SERVER_STATUS_EXTERNAL_URL or "").strip() return url or None diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 40300c42..051c2369 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -965,8 +965,7 @@ async def create_subscription( device_limit: int = 1, connected_squads: list = None, remnawave_short_uuid: str = None, - subscription_url: str = "", - happ_crypto_link: Optional[str] = None, + subscription_url: str = "" ) -> Subscription: if end_date is None: @@ -985,8 +984,7 @@ async def create_subscription( device_limit=device_limit, connected_squads=connected_squads, remnawave_short_uuid=remnawave_short_uuid, - subscription_url=subscription_url, - happ_crypto_link=happ_crypto_link, + subscription_url=subscription_url ) db.add(subscription) diff --git a/app/database/models.py b/app/database/models.py index 36ad19db..0a19fe07 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -435,8 +435,7 @@ class Subscription(Base): traffic_used_gb = Column(Float, default=0.0) subscription_url = Column(String, nullable=True) - happ_crypto_link = Column(String, nullable=True) - + device_limit = Column(Integer, default=1) connected_squads = Column(JSON, default=list) diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index ca80b3f2..ec553efb 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -58,7 +58,6 @@ class RemnaWaveUser: ss_password: Optional[str] = None first_connected_at: Optional[datetime] = None last_triggered_threshold: int = 0 - happ: Optional[Dict[str, Any]] = None @dataclass @@ -92,7 +91,7 @@ class SubscriptionInfo: links: List[str] ss_conf_links: Dict[str, str] subscription_url: str - happ: Optional[Dict[str, Any]] + happ: Optional[Dict[str, str]] class RemnaWaveAPIError(Exception): @@ -613,8 +612,7 @@ class RemnaWaveAPI: vless_uuid=user_data.get('vlessUuid'), ss_password=user_data.get('ssPassword'), first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')), - last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0), - happ=user_data.get('happ'), + last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0) ) def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]: diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index ea96e031..4c0b14a2 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -877,41 +877,6 @@ async def activate_trial( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode == "happ_cryptolink": - happ_link = getattr(subscription, 'happ_crypto_link', None) - buttons = [] - - if happ_link: - buttons.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=happ_link, - ) - ]) - else: - buttons.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect", - ) - ]) - - if settings.is_happ_download_button_enabled(): - buttons.append([ - InlineKeyboardButton( - text=texts.t("DOWNLOAD_HAPP_APP_BUTTON", "📲 Скачать приложение Happ"), - callback_data="download_happ_app", - ) - ]) - - buttons.append([ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ]) - - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) elif connect_mode == "link": connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)], @@ -3358,41 +3323,6 @@ async def confirm_purchase( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode == "happ_cryptolink": - happ_link = getattr(subscription, 'happ_crypto_link', None) - buttons = [] - - if happ_link: - buttons.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=happ_link, - ) - ]) - else: - buttons.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect", - ) - ]) - - if settings.is_happ_download_button_enabled(): - buttons.append([ - InlineKeyboardButton( - text=texts.t("DOWNLOAD_HAPP_APP_BUTTON", "📲 Скачать приложение Happ"), - callback_data="download_happ_app", - ) - ]) - - buttons.append([ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ]) - - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) elif connect_mode == "link": connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)], @@ -4086,7 +4016,7 @@ async def handle_connect_subscription( texts = get_texts(db_user.language) subscription = db_user.subscription - if not subscription: + if not subscription or not subscription.subscription_url: await callback.answer( texts.t( "SUBSCRIPTION_NO_ACTIVE_LINK", @@ -4098,26 +4028,6 @@ async def handle_connect_subscription( connect_mode = settings.CONNECT_BUTTON_MODE - if connect_mode != "happ_cryptolink" and not subscription.subscription_url: - await callback.answer( - texts.t( - "SUBSCRIPTION_NO_ACTIVE_LINK", - "⚠ У вас нет активной подписки или ссылка еще генерируется", - ), - show_alert=True, - ) - return - - if connect_mode == "happ_cryptolink" and not getattr(subscription, 'happ_crypto_link', None): - await callback.answer( - texts.t( - "SUBSCRIPTION_HAPP_CRYPTO_LINK_MISSING", - "⚠ Ссылка Happ пока недоступна. Попробуйте позже.", - ), - show_alert=True, - ) - return - if connect_mode == "miniapp_subscription": keyboard = InlineKeyboardMarkup(inline_keyboard=[ [ @@ -4176,53 +4086,6 @@ async def handle_connect_subscription( parse_mode="HTML" ) - elif connect_mode == "happ_cryptolink": - happ_link = getattr(subscription, 'happ_crypto_link', None) - - if not happ_link: - await callback.answer( - texts.t( - "SUBSCRIPTION_HAPP_CRYPTO_LINK_MISSING", - "⚠ Ссылка Happ пока недоступна. Попробуйте позже.", - ), - show_alert=True, - ) - return - - buttons = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=happ_link, - ) - ] - ] - - if settings.is_happ_download_button_enabled(): - buttons.append([ - InlineKeyboardButton( - text=texts.t("DOWNLOAD_HAPP_APP_BUTTON", "📲 Скачать приложение Happ"), - callback_data="download_happ_app", - ) - ]) - - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) - - keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) - - await callback.message.edit_text( - texts.t( - "SUBSCRIPTION_CONNECT_HAPP_MESSAGE", - """📱 Подключить подписку Happ - -🚀 Нажмите кнопку ниже, чтобы открыть ссылку Happ для подключения подписки.""", - ), - reply_markup=keyboard, - parse_mode="HTML", - ) - elif connect_mode == "link": keyboard = InlineKeyboardMarkup(inline_keyboard=[ [ @@ -4263,133 +4126,7 @@ async def handle_connect_subscription( reply_markup=get_device_selection_keyboard(db_user.language), parse_mode="HTML" ) - - await callback.answer() - - -async def handle_download_happ_app( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - - if not settings.is_happ_download_button_enabled(): - await callback.answer( - texts.t( - "DOWNLOAD_HAPP_APP_NOT_AVAILABLE", - "⚠️ Загрузка Happ временно недоступна.", - ), - show_alert=True, - ) - return - - options = [] - for platform in ("ios", "android", "pc"): - link = settings.get_happ_download_link(platform) - if not link: - continue - - options.append([ - InlineKeyboardButton( - text=texts.t( - f"DOWNLOAD_HAPP_DEVICE_{platform.upper()}", - { - "ios": "📱 iOS", - "android": "🤖 Android", - "pc": "💻 ПК", - }[platform], - ), - callback_data=f"download_happ_app_{platform}", - ) - ]) - - if not options: - await callback.answer( - texts.t( - "DOWNLOAD_HAPP_APP_NOT_AVAILABLE", - "⚠️ Загрузка Happ временно недоступна.", - ), - show_alert=True, - ) - return - - options.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="subscription_connect") - ]) - options.append([ - InlineKeyboardButton( - text=texts.t("BACK_TO_SUBSCRIPTION", "⬅️ К подписке"), - callback_data="menu_subscription", - ) - ]) - - await callback.message.edit_text( - texts.t( - "DOWNLOAD_HAPP_APP_PROMPT", - """📲 Скачать Happ - -Выберите устройство, чтобы получить ссылку на загрузку:""", - ), - reply_markup=InlineKeyboardMarkup(inline_keyboard=options), - parse_mode="HTML", - ) - await callback.answer() - - -async def handle_download_happ_device( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - _, _, platform = callback.data.partition("download_happ_app_") - platform = platform or "" - texts = get_texts(db_user.language) - - link = settings.get_happ_download_link(platform) - if not link: - await callback.answer( - texts.t( - "DOWNLOAD_HAPP_APP_LINK_MISSING", - "⚠️ Ссылка для выбранного устройства недоступна.", - ), - show_alert=True, - ) - return - - device_name = get_happ_device_name(platform, db_user.language) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t( - "DOWNLOAD_HAPP_OPEN_STORE", - "📥 Открыть в магазине", - ), - url=link, - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="download_happ_app")], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_SUBSCRIPTION", "⬅️ К подписке"), - callback_data="menu_subscription", - ) - ], - ] - ) - - await callback.message.edit_text( - texts.t( - "DOWNLOAD_HAPP_APP_LINK_MESSAGE", - """📥 Скачать Happ для {device_name} - -Нажмите кнопку ниже, чтобы открыть приложение в магазине.""", - ).format(device_name=device_name), - reply_markup=keyboard, - parse_mode="HTML", - ) + await callback.answer() @@ -4725,7 +4462,7 @@ def load_app_config() -> Dict[str, Any]: def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str, Any]]: config = load_app_config() - + device_mapping = { 'ios': 'ios', 'android': 'android', @@ -4738,25 +4475,6 @@ def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str return config.get(config_key, []) -def get_happ_device_name(platform: str, language: str = "ru") -> str: - platform = (platform or "").lower() - - if language == "en": - names = { - "ios": "iOS", - "android": "Android", - "pc": "PC", - } - else: - names = { - "ios": "iOS", - "android": "Android", - "pc": "ПК", - } - - return names.get(platform, platform.upper() or platform) - - def get_device_name(device_type: str, language: str = "ru") -> str: if language == "en": names = { @@ -5407,16 +5125,6 @@ def register_handlers(dp: Dispatcher): F.data == "open_subscription_link" ) - dp.callback_query.register( - handle_download_happ_app, - F.data == "download_happ_app", - ) - - dp.callback_query.register( - handle_download_happ_device, - F.data.startswith("download_happ_app_"), - ) - dp.callback_query.register( handle_subscription_settings, F.data == "subscription_settings" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 41a455e5..c367b562 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -112,25 +112,6 @@ def get_main_menu_keyboard( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) ) ]) - elif connect_mode == "happ_cryptolink": - happ_link = getattr(subscription, "happ_crypto_link", None) - if happ_link: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=happ_link, - ) - ]) - else: - keyboard.append([_fallback_connect_button()]) - - if settings.is_happ_download_button_enabled(): - keyboard.append([ - InlineKeyboardButton( - text=texts.t("DOWNLOAD_HAPP_APP_BUTTON", "📲 Скачать приложение Happ"), - callback_data="download_happ_app", - ) - ]) elif connect_mode == "link": if subscription_url: keyboard.append([ diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index f489e5b2..8fba5d67 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -310,11 +310,9 @@ "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", - "SUBSCRIPTION_CONNECT_HAPP_MESSAGE": "📱 Connect Happ subscription\n\n🚀 Click the button below to open the Happ link and connect your subscription.", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", - "SUBSCRIPTION_HAPP_CRYPTO_LINK_MISSING": "⚠ Happ link is temporarily unavailable. Please try again later.", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", @@ -338,15 +336,6 @@ "SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option", "SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link", "SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.", - "DOWNLOAD_HAPP_APP_BUTTON": "📲 Download Happ app", - "DOWNLOAD_HAPP_APP_PROMPT": "📲 Download Happ\n\nChoose your device to get the store link:", - "DOWNLOAD_HAPP_APP_NOT_AVAILABLE": "⚠️ Happ download is temporarily unavailable.", - "DOWNLOAD_HAPP_APP_LINK_MISSING": "⚠️ The download link for the selected device is unavailable.", - "DOWNLOAD_HAPP_DEVICE_IOS": "📱 iOS", - "DOWNLOAD_HAPP_DEVICE_ANDROID": "🤖 Android", - "DOWNLOAD_HAPP_DEVICE_PC": "💻 PC", - "DOWNLOAD_HAPP_OPEN_STORE": "📥 Open in store", - "DOWNLOAD_HAPP_APP_LINK_MESSAGE": "📥 Download Happ for {device_name}\n\nTap the button below to open the app in the store.", "REFERRAL_PROGRAM_TITLE": "👥 Referral program", "REFERRAL_STATS_HEADER": "📊 Your statistics:", "REFERRAL_STATS_INVITED": "• Invited users: {count}", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index f53de4c9..d51eee5b 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -312,11 +312,9 @@ "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", - "SUBSCRIPTION_CONNECT_HAPP_MESSAGE": "📱 Подключить подписку Happ\n\n🚀 Нажмите кнопку ниже, чтобы открыть ссылку Happ для подключения подписки.", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", - "SUBSCRIPTION_HAPP_CRYPTO_LINK_MISSING": "⚠ Ссылка Happ пока недоступна. Попробуйте позже.", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", @@ -340,15 +338,6 @@ "SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"", "SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку", "SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", - "DOWNLOAD_HAPP_APP_BUTTON": "📲 Скачать приложение Happ", - "DOWNLOAD_HAPP_APP_PROMPT": "📲 Скачать Happ\n\nВыберите устройство, чтобы получить ссылку на загрузку:", - "DOWNLOAD_HAPP_APP_NOT_AVAILABLE": "⚠️ Загрузка Happ временно недоступна.", - "DOWNLOAD_HAPP_APP_LINK_MISSING": "⚠️ Ссылка для выбранного устройства недоступна.", - "DOWNLOAD_HAPP_DEVICE_IOS": "📱 iOS", - "DOWNLOAD_HAPP_DEVICE_ANDROID": "🤖 Android", - "DOWNLOAD_HAPP_DEVICE_PC": "💻 ПК", - "DOWNLOAD_HAPP_OPEN_STORE": "📥 Открыть в магазине", - "DOWNLOAD_HAPP_APP_LINK_MESSAGE": "📥 Скачать Happ для {device_name}\n\nНажмите кнопку ниже, чтобы открыть приложение в магазине.", "REFERRAL_PROGRAM_TITLE": "👥 Реферальная программа", "REFERRAL_STATS_HEADER": "📊 Ваша статистика:", "REFERRAL_STATS_INVITED": "• Приглашено пользователей: {count}", diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 20490595..dd6ee50c 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -634,25 +634,19 @@ class RemnaWaveService: elif isinstance(squad, str): squad_uuids.append(squad) - happ_crypto_link = None - happ_data = panel_user.get('happ') - if isinstance(happ_data, dict): - happ_crypto_link = happ_data.get('cryptoLink') or None - subscription_data = { 'user_id': user.id, 'status': status.value, - 'is_trial': False, + 'is_trial': False, 'end_date': expire_at, 'traffic_limit_gb': traffic_limit_gb, 'traffic_used_gb': traffic_used_gb, 'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1, 'connected_squads': squad_uuids, 'remnawave_short_uuid': panel_user.get('shortUuid'), - 'subscription_url': panel_user.get('subscriptionUrl', ''), - 'happ_crypto_link': happ_crypto_link, + 'subscription_url': panel_user.get('subscriptionUrl', '') } - + subscription = await create_subscription(db, **subscription_data) logger.info(f"✅ Создана подписка для пользователя {user.telegram_id} до {expire_at}") @@ -673,8 +667,7 @@ class RemnaWaveService: device_limit=1, connected_squads=[], remnawave_short_uuid=panel_user.get('shortUuid'), - subscription_url=panel_user.get('subscriptionUrl', ''), - happ_crypto_link=happ_crypto_link, + subscription_url=panel_user.get('subscriptionUrl', '') ) logger.info(f"✅ Создана базовая подписка для пользователя {user.telegram_id}") except Exception as basic_error: @@ -740,15 +733,7 @@ class RemnaWaveService: panel_url = panel_user.get('subscriptionUrl', '') if not subscription.subscription_url or subscription.subscription_url != panel_url: subscription.subscription_url = panel_url - - happ_crypto_link = None - happ_data = panel_user.get('happ') - if isinstance(happ_data, dict): - happ_crypto_link = happ_data.get('cryptoLink') or None - - if subscription.happ_crypto_link != happ_crypto_link: - subscription.happ_crypto_link = happ_crypto_link - + active_squads = panel_user.get('activeInternalSquads', []) squad_uuids = [] if isinstance(active_squads, list): diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 37837237..7e25c427 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -130,10 +130,7 @@ class SubscriptionService: ) subscription.remnawave_short_uuid = updated_user.short_uuid - subscription.subscription_url = updated_user.subscription_url - happ_data = getattr(updated_user, 'happ', None) - if isinstance(happ_data, dict): - subscription.happ_crypto_link = happ_data.get('cryptoLink') or None + subscription.subscription_url = updated_user.subscription_url user.remnawave_uuid = updated_user.uuid await db.commit() @@ -193,9 +190,6 @@ class SubscriptionService: ) subscription.subscription_url = updated_user.subscription_url - happ_data = getattr(updated_user, 'happ', None) - if isinstance(happ_data, dict): - subscription.happ_crypto_link = happ_data.get('cryptoLink') or None await db.commit() status_text = "активным" if is_actually_active else "истёкшим" diff --git a/locales/en.json b/locales/en.json index 45730a1f..bb1b7fc8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -391,11 +391,9 @@ "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", - "SUBSCRIPTION_CONNECT_HAPP_MESSAGE": "📱 Connect Happ subscription\n\n🚀 Click the button below to open the Happ link and connect your subscription.", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", - "SUBSCRIPTION_HAPP_CRYPTO_LINK_MISSING": "⚠ Happ link is temporarily unavailable. Please try again later.", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", @@ -419,15 +417,6 @@ "SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option", "SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link", "SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.", - "DOWNLOAD_HAPP_APP_BUTTON": "📲 Download Happ app", - "DOWNLOAD_HAPP_APP_PROMPT": "📲 Download Happ\n\nChoose your device to get the store link:", - "DOWNLOAD_HAPP_APP_NOT_AVAILABLE": "⚠️ Happ download is temporarily unavailable.", - "DOWNLOAD_HAPP_APP_LINK_MISSING": "⚠️ The download link for the selected device is unavailable.", - "DOWNLOAD_HAPP_DEVICE_IOS": "📱 iOS", - "DOWNLOAD_HAPP_DEVICE_ANDROID": "🤖 Android", - "DOWNLOAD_HAPP_DEVICE_PC": "💻 PC", - "DOWNLOAD_HAPP_OPEN_STORE": "📥 Open in store", - "DOWNLOAD_HAPP_APP_LINK_MESSAGE": "📥 Download Happ for {device_name}\n\nTap the button below to open the app in the store.", "REFERRAL_PROGRAM_TITLE": "👥 Referral program", "REFERRAL_STATS_HEADER": "📊 Your statistics:", "REFERRAL_STATS_INVITED": "• Invited users: {count}", diff --git a/locales/ru.json b/locales/ru.json index 69e8a37c..ff9fa404 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -391,11 +391,9 @@ "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", - "SUBSCRIPTION_CONNECT_HAPP_MESSAGE": "📱 Подключить подписку Happ\n\n🚀 Нажмите кнопку ниже, чтобы открыть ссылку Happ для подключения подписки.", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", - "SUBSCRIPTION_HAPP_CRYPTO_LINK_MISSING": "⚠ Ссылка Happ пока недоступна. Попробуйте позже.", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", @@ -419,15 +417,6 @@ "SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"", "SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку", "SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", - "DOWNLOAD_HAPP_APP_BUTTON": "📲 Скачать приложение Happ", - "DOWNLOAD_HAPP_APP_PROMPT": "📲 Скачать Happ\n\nВыберите устройство, чтобы получить ссылку на загрузку:", - "DOWNLOAD_HAPP_APP_NOT_AVAILABLE": "⚠️ Загрузка Happ временно недоступна.", - "DOWNLOAD_HAPP_APP_LINK_MISSING": "⚠️ Ссылка для выбранного устройства недоступна.", - "DOWNLOAD_HAPP_DEVICE_IOS": "📱 iOS", - "DOWNLOAD_HAPP_DEVICE_ANDROID": "🤖 Android", - "DOWNLOAD_HAPP_DEVICE_PC": "💻 ПК", - "DOWNLOAD_HAPP_OPEN_STORE": "📥 Открыть в магазине", - "DOWNLOAD_HAPP_APP_LINK_MESSAGE": "📥 Скачать Happ для {device_name}\n\nНажмите кнопку ниже, чтобы открыть приложение в магазине.", "REFERRAL_PROGRAM_TITLE": "👥 Реферальная программа", "REFERRAL_STATS_HEADER": "📊 Ваша статистика:", "REFERRAL_STATS_INVITED": "• Приглашено пользователей: {count}", diff --git a/migrations/alembic/versions/b7a0e4031581_add_happ_crypto_link_to_subscriptions.py b/migrations/alembic/versions/b7a0e4031581_add_happ_crypto_link_to_subscriptions.py deleted file mode 100644 index 11ba9519..00000000 --- a/migrations/alembic/versions/b7a0e4031581_add_happ_crypto_link_to_subscriptions.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "b7a0e4031581" -down_revision: Union[str, None] = "5d1f1f8b2e9a" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -SUBSCRIPTIONS_TABLE = "subscriptions" -HAPP_CRYPTO_LINK_COLUMN = "happ_crypto_link" - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if SUBSCRIPTIONS_TABLE in inspector.get_table_names(): - columns = {column["name"] for column in inspector.get_columns(SUBSCRIPTIONS_TABLE)} - if HAPP_CRYPTO_LINK_COLUMN not in columns: - op.add_column( - SUBSCRIPTIONS_TABLE, - sa.Column(HAPP_CRYPTO_LINK_COLUMN, sa.String(), nullable=True), - ) - else: - op.add_column( - SUBSCRIPTIONS_TABLE, - sa.Column(HAPP_CRYPTO_LINK_COLUMN, sa.String(), nullable=True), - ) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if SUBSCRIPTIONS_TABLE in inspector.get_table_names(): - columns = {column["name"] for column in inspector.get_columns(SUBSCRIPTIONS_TABLE)} - if HAPP_CRYPTO_LINK_COLUMN in columns: - op.drop_column(SUBSCRIPTIONS_TABLE, HAPP_CRYPTO_LINK_COLUMN) From 54d798f3584adda06113e7f11c27af1fe3dcd5e1 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 04:31:44 +0300 Subject: [PATCH 076/146] Add Happ cryptoLink mode and download support --- .env.example | 7 + README.md | 7 + app/config.py | 16 +++ app/database/crud/subscription.py | 6 +- app/database/models.py | 3 +- app/database/universal_migration.py | 37 +++++ app/external/remnawave_api.py | 4 +- app/handlers/subscription.py | 200 +++++++++++++++++++++++++-- app/keyboards/inline.py | 106 ++++++++++---- app/services/monitoring_service.py | 3 +- app/services/remnawave_service.py | 18 ++- app/services/subscription_service.py | 8 +- locales/en.json | 12 ++ locales/ru.json | 12 ++ 14 files changed, 389 insertions(+), 50 deletions(-) diff --git a/.env.example b/.env.example index 41eb0876..2aa0956e 100644 --- a/.env.example +++ b/.env.example @@ -280,11 +280,18 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) +# happ_cryptolink - открывает happ cryptoLink из панели (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +# Кнопка скачивания Happ (активна только при CONNECT_BUTTON_MODE=happ_cryptolink) +HAPP_DOWNLOAD_BUTTON_ENABLED=false +HAPP_DOWNLOAD_LINK_IOS= +HAPP_DOWNLOAD_LINK_ANDROID= +HAPP_DOWNLOAD_LINK_PC= + # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода diff --git a/README.md b/README.md index 86b0f6dd..6ccd2b95 100644 --- a/README.md +++ b/README.md @@ -521,11 +521,18 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) +# happ_cryptolink - открывает happ cryptoLink из панели (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +# Кнопка скачивания Happ (активна только при CONNECT_BUTTON_MODE=happ_cryptolink) +HAPP_DOWNLOAD_BUTTON_ENABLED=false +HAPP_DOWNLOAD_LINK_IOS= +HAPP_DOWNLOAD_LINK_ANDROID= +HAPP_DOWNLOAD_LINK_PC= + # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода diff --git a/app/config.py b/app/config.py index a5bee373..f7b7d795 100644 --- a/app/config.py +++ b/app/config.py @@ -209,6 +209,10 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" + HAPP_DOWNLOAD_BUTTON_ENABLED: bool = False + HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None + HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None + HAPP_DOWNLOAD_LINK_PC: Optional[str] = None HIDE_SUBSCRIPTION_LINK: bool = False ENABLE_LOGO_MODE: bool = True LOGO_FILE: str = "vpn_logo.png" @@ -543,6 +547,18 @@ class Settings(BaseSettings): def get_cryptobot_invoice_expires_seconds(self) -> int: return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600 + def is_happ_download_button_enabled(self) -> bool: + return self.HAPP_DOWNLOAD_BUTTON_ENABLED + + def get_happ_download_link(self, platform: str) -> Optional[str]: + platform_key = (platform or "").strip().lower() + links = { + "ios": self.HAPP_DOWNLOAD_LINK_IOS, + "android": self.HAPP_DOWNLOAD_LINK_ANDROID, + "pc": self.HAPP_DOWNLOAD_LINK_PC, + } + return links.get(platform_key) + def is_maintenance_mode(self) -> bool: return self.MAINTENANCE_MODE diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 051c2369..2670227b 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -965,7 +965,8 @@ async def create_subscription( device_limit: int = 1, connected_squads: list = None, remnawave_short_uuid: str = None, - subscription_url: str = "" + subscription_url: str = "", + happ_crypto_link: str = None, ) -> Subscription: if end_date is None: @@ -984,7 +985,8 @@ async def create_subscription( device_limit=device_limit, connected_squads=connected_squads, remnawave_short_uuid=remnawave_short_uuid, - subscription_url=subscription_url + subscription_url=subscription_url, + happ_crypto_link=happ_crypto_link ) db.add(subscription) diff --git a/app/database/models.py b/app/database/models.py index 0a19fe07..36ad19db 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -435,7 +435,8 @@ class Subscription(Base): traffic_used_gb = Column(Float, default=0.0) subscription_url = Column(String, nullable=True) - + happ_crypto_link = Column(String, nullable=True) + device_limit = Column(Integer, default=1) connected_squads = Column(JSON, default=list) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index eaa9d068..15a462fd 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1366,6 +1366,33 @@ async def add_ticket_sla_columns(): logger.error(f"Ошибка добавления SLA колонки в tickets: {e}") return False + +async def add_happ_crypto_link_column() -> bool: + logger.info("=== ДОБАВЛЕНИЕ ПОЛЯ HAPP_CRYPTO_LINK В SUBSCRIPTIONS ===") + + try: + column_exists = await check_column_exists('subscriptions', 'happ_crypto_link') + if column_exists: + logger.info("ℹ️ Поле happ_crypto_link уже существует") + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type in ('sqlite', 'postgresql', 'mysql'): + alter_sql = "ALTER TABLE subscriptions ADD COLUMN happ_crypto_link TEXT" + else: + logger.error(f"Неподдерживаемый тип БД для добавления поля: {db_type}") + return False + + await conn.execute(text(alter_sql)) + logger.info("✅ Поле happ_crypto_link успешно добавлено") + return True + + except Exception as e: + logger.error(f"Ошибка добавления поля happ_crypto_link: {e}") + return False + async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: @@ -1799,6 +1826,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением полей SLA в tickets") + logger.info("=== ДОБАВЛЕНИЕ ПОЛЯ HAPP_CRYPTO_LINK В SUBSCRIPTIONS ===") + happ_crypto_added = await add_happ_crypto_link_column() + if happ_crypto_added: + logger.info("✅ Поле happ_crypto_link в subscriptions готово") + else: + logger.warning("⚠️ Проблемы с добавлением поля happ_crypto_link в subscriptions") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===") try: async with engine.begin() as conn: @@ -1955,6 +1989,7 @@ async def check_migration_status(): "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, "users_auto_promo_group_assigned_column": False, + "subscriptions_happ_crypto_link_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1971,6 +2006,7 @@ async def check_migration_status(): status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') + status["subscriptions_happ_crypto_link_column"] = await check_column_exists('subscriptions', 'happ_crypto_link') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -2007,6 +2043,7 @@ async def check_migration_status(): "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", + "subscriptions_happ_crypto_link_column": "Поле happ_crypto_link в subscriptions", } for check_key, check_status in status.items(): diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index ec553efb..21c58498 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -58,6 +58,7 @@ class RemnaWaveUser: ss_password: Optional[str] = None first_connected_at: Optional[datetime] = None last_triggered_threshold: int = 0 + happ_crypto_link: Optional[str] = None @dataclass @@ -612,7 +613,8 @@ class RemnaWaveAPI: vless_uuid=user_data.get('vlessUuid'), ss_password=user_data.get('ssPassword'), first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')), - last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0) + last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0), + happ_crypto_link=(user_data.get('happ') or {}).get('cryptoLink') ) def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]: diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 4c0b14a2..f1a26d14 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -41,7 +41,9 @@ from app.keyboards.inline import ( get_device_management_help_keyboard, get_payment_methods_keyboard_with_cart, get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart + get_insufficient_balance_keyboard_with_cart, + get_happ_download_device_keyboard, + get_happ_download_link_keyboard, ) from app.localization.texts import get_texts from app.services.remnawave_service import RemnaWaveService @@ -2637,7 +2639,10 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess countries_info = await _get_countries_info(subscription.connected_squads) countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет" - subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..." + if settings.CONNECT_BUTTON_MODE == "happ_cryptolink": + subscription_url = getattr(subscription, 'happ_crypto_link', None) or "Генерируется..." + else: + subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..." if subscription.is_trial: status_text = "🎁 Тестовая" @@ -4015,8 +4020,11 @@ async def handle_connect_subscription( ): texts = get_texts(db_user.language) subscription = db_user.subscription - - if not subscription or not subscription.subscription_url: + connect_mode = settings.CONNECT_BUTTON_MODE + subscription_url = getattr(subscription, "subscription_url", None) if subscription else None + happ_crypto_link = getattr(subscription, "happ_crypto_link", None) if subscription else None + + if not subscription: await callback.answer( texts.t( "SUBSCRIPTION_NO_ACTIVE_LINK", @@ -4026,14 +4034,32 @@ async def handle_connect_subscription( ) return - connect_mode = settings.CONNECT_BUTTON_MODE + if connect_mode == "happ_cryptolink" and not happ_crypto_link: + await callback.answer( + texts.t( + "SUBSCRIPTION_NO_ACTIVE_LINK", + "⚠ У вас нет активной подписки или ссылка еще генерируется", + ), + show_alert=True, + ) + return + + if connect_mode != "happ_cryptolink" and not subscription_url: + await callback.answer( + texts.t( + "SUBSCRIPTION_NO_ACTIVE_LINK", + "⚠ У вас нет активной подписки или ссылка еще генерируется", + ), + show_alert=True, + ) + return if connect_mode == "miniapp_subscription": keyboard = InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription.subscription_url) + web_app=types.WebAppInfo(url=subscription_url) ) ], [ @@ -4091,7 +4117,7 @@ async def handle_connect_subscription( [ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription.subscription_url + url=subscription_url ) ], [ @@ -4110,6 +4136,41 @@ async def handle_connect_subscription( parse_mode="HTML" ) + elif connect_mode == "happ_cryptolink": + keyboard_rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=happ_crypto_link, + ) + ] + ] + + if settings.is_happ_download_button_enabled(): + keyboard_rows.append([ + InlineKeyboardButton( + text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), + callback_data="happ_download", + ) + ]) + + keyboard_rows.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + + await callback.message.edit_text( + texts.t( + "SUBSCRIPTION_CONNECT_HAPP_MESSAGE", + """📱 Подключить Happ + +🚀 Нажмите кнопку ниже, чтобы открыть подписку в приложении Happ.""", + ), + reply_markup=keyboard, + parse_mode="HTML", + ) + else: device_text = texts.t( "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE", @@ -4119,14 +4180,111 @@ async def handle_connect_subscription( {subscription_url} 💡 Выберите ваше устройство для получения подробной инструкции по настройке:""", - ).format(subscription_url=subscription.subscription_url) + ).format(subscription_url=subscription_url) - await callback.message.edit_text( - device_text, - reply_markup=get_device_selection_keyboard(db_user.language), - parse_mode="HTML" + await callback.message.edit_text( + device_text, + reply_markup=get_device_selection_keyboard(db_user.language), + parse_mode="HTML" + ) + + await callback.answer() + + +async def handle_happ_download_request( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + if not settings.is_happ_download_button_enabled(): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("HAPP_DOWNLOAD_DISABLED", "⚠️ Загрузка приложения сейчас недоступна"), + show_alert=True, ) - + return + + texts = get_texts(db_user.language) + await callback.message.answer( + texts.t( + "HAPP_DOWNLOAD_SELECT_DEVICE", + "📥 Скачать Happ\n\nВыберите устройство, чтобы получить ссылку на приложение:", + ), + reply_markup=get_happ_download_device_keyboard(db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +async def handle_happ_download_device( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + if not settings.is_happ_download_button_enabled(): + await callback.answer() + return + + platform = callback.data.rsplit('_', maxsplit=1)[-1] + link = settings.get_happ_download_link(platform) + texts = get_texts(db_user.language) + + if not link: + await callback.answer( + texts.t("HAPP_DOWNLOAD_LINK_NOT_AVAILABLE", "❌ Ссылка для выбранной платформы недоступна"), + show_alert=True, + ) + return + + device_names = { + "ios": texts.t("HAPP_DOWNLOAD_DEVICE_IOS", "🍎 iOS"), + "android": texts.t("HAPP_DOWNLOAD_DEVICE_ANDROID", "🤖 Android"), + "pc": texts.t("HAPP_DOWNLOAD_DEVICE_PC", "💻 ПК"), + } + + message_text = texts.t( + "HAPP_DOWNLOAD_LINK_PROMPT", + "📥 Скачайте Happ для {device_name}:", + ).format(device_name=device_names.get(platform, platform.upper())) + + await callback.message.edit_text( + message_text, + reply_markup=get_happ_download_link_keyboard(link, db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +async def handle_happ_download_back( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + if not settings.is_happ_download_button_enabled(): + await callback.answer() + return + + texts = get_texts(db_user.language) + await callback.message.edit_text( + texts.t( + "HAPP_DOWNLOAD_SELECT_DEVICE", + "📥 Скачать Happ\n\nВыберите устройство, чтобы получить ссылку на приложение:", + ), + reply_markup=get_happ_download_device_keyboard(db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +async def handle_happ_download_close( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + try: + await callback.message.delete() + except Exception: + pass await callback.answer() @@ -5104,6 +5262,22 @@ def register_handlers(dp: Dispatcher): handle_connect_subscription, F.data == "subscription_connect" ) + dp.callback_query.register( + handle_happ_download_request, + F.data == "happ_download" + ) + dp.callback_query.register( + handle_happ_download_device, + F.data.startswith("happ_download_device_") + ) + dp.callback_query.register( + handle_happ_download_back, + F.data == "happ_download_back" + ) + dp.callback_query.register( + handle_happ_download_close, + F.data == "happ_download_close" + ) dp.callback_query.register( handle_device_guide, diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index c367b562..da9313fb 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -88,6 +88,7 @@ def get_main_menu_keyboard( if has_active_subscription and subscription_is_active: connect_mode = settings.CONNECT_BUTTON_MODE subscription_url = getattr(subscription, "subscription_url", None) + happ_crypto_link = getattr(subscription, "happ_crypto_link", None) def _fallback_connect_button() -> InlineKeyboardButton: return InlineKeyboardButton( @@ -122,6 +123,23 @@ def get_main_menu_keyboard( ]) else: keyboard.append([_fallback_connect_button()]) + elif connect_mode == "happ_cryptolink": + if happ_crypto_link: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=happ_crypto_link + ) + ]) + if settings.is_happ_download_button_enabled(): + keyboard.append([ + InlineKeyboardButton( + text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), + callback_data="happ_download", + ) + ]) + else: + keyboard.append([_fallback_connect_button()]) else: keyboard.append([_fallback_connect_button()]) @@ -323,36 +341,48 @@ def get_subscription_keyboard( keyboard = [] if has_subscription: - if subscription and subscription.subscription_url: - connect_mode = settings.CONNECT_BUTTON_MODE - - if connect_mode == "miniapp_subscription": + connect_mode = settings.CONNECT_BUTTON_MODE + subscription_url = getattr(subscription, "subscription_url", None) + happ_crypto_link = getattr(subscription, "happ_crypto_link", None) + + if connect_mode == "miniapp_subscription" and subscription_url: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + web_app=types.WebAppInfo(url=subscription_url) + ) + ]) + elif connect_mode == "miniapp_custom": + if settings.MINIAPP_CUSTOM_URL: keyboard.append([ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription.subscription_url) + web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) ) ]) - elif connect_mode == "miniapp_custom": - if settings.MINIAPP_CUSTOM_URL: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) - ) - ]) - else: - keyboard.append([ - InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") - ]) - elif connect_mode == "link": - keyboard.append([ - InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url) - ]) else: keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") ]) + elif connect_mode == "link" and subscription_url: + keyboard.append([ + InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_url) + ]) + elif connect_mode == "happ_cryptolink" and happ_crypto_link: + keyboard.append([ + InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=happ_crypto_link) + ]) + if settings.is_happ_download_button_enabled(): + keyboard.append([ + InlineKeyboardButton( + text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), + callback_data="happ_download", + ) + ]) + else: + keyboard.append([ + InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") + ]) if not is_trial: keyboard.append([ @@ -1265,8 +1295,8 @@ def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKey def get_connection_guide_keyboard( - subscription_url: str, - app: dict, + subscription_url: str, + app: dict, language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: from app.handlers.subscription import create_deep_link @@ -1305,8 +1335,8 @@ def get_connection_guide_keyboard( def get_app_selection_keyboard( - device_type: str, - apps: list, + device_type: str, + apps: list, language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -1336,6 +1366,32 @@ def get_app_selection_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) +def get_happ_download_device_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_DEVICE_IOS", "🍎 iOS"), callback_data="happ_download_device_ios")], + [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_DEVICE_ANDROID", "🤖 Android"), callback_data="happ_download_device_android")], + [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_DEVICE_PC", "💻 ПК"), callback_data="happ_download_device_pc")], + [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_CLOSE", "❌ Закрыть"), callback_data="happ_download_close")], + ] + ) + + +def get_happ_download_link_keyboard( + link: str, + language: str = DEFAULT_LANGUAGE, +) -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_OPEN_LINK", "📥 Открыть ссылку"), url=link)], + [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_BACK", "⬅️ Назад"), callback_data="happ_download_back")], + [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_CLOSE", "❌ Закрыть"), callback_data="happ_download_close")], + ] + ) + + def get_specific_app_keyboard( subscription_url: str, app: dict, diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 1f7680f5..3502b080 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -277,8 +277,9 @@ class MonitoringService: ), active_internal_squads=subscription.connected_squads ) - + subscription.subscription_url = updated_user.subscription_url + subscription.happ_crypto_link = updated_user.happ_crypto_link await db.commit() status_text = "активным" if is_active else "истёкшим" diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index dd6ee50c..b3b0e11c 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -581,7 +581,8 @@ class RemnaWaveService: subscription.autopay_enabled = False subscription.remnawave_short_uuid = None subscription.subscription_url = "" - + subscription.happ_crypto_link = None + db_user.remnawave_uuid = None await db.commit() @@ -637,14 +638,15 @@ class RemnaWaveService: subscription_data = { 'user_id': user.id, 'status': status.value, - 'is_trial': False, + 'is_trial': False, 'end_date': expire_at, 'traffic_limit_gb': traffic_limit_gb, 'traffic_used_gb': traffic_used_gb, 'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1, 'connected_squads': squad_uuids, 'remnawave_short_uuid': panel_user.get('shortUuid'), - 'subscription_url': panel_user.get('subscriptionUrl', '') + 'subscription_url': panel_user.get('subscriptionUrl', ''), + 'happ_crypto_link': (panel_user.get('happ') or {}).get('cryptoLink') } subscription = await create_subscription(db, **subscription_data) @@ -667,7 +669,8 @@ class RemnaWaveService: device_limit=1, connected_squads=[], remnawave_short_uuid=panel_user.get('shortUuid'), - subscription_url=panel_user.get('subscriptionUrl', '') + subscription_url=panel_user.get('subscriptionUrl', ''), + happ_crypto_link=(panel_user.get('happ') or {}).get('cryptoLink') ) logger.info(f"✅ Создана базовая подписка для пользователя {user.telegram_id}") except Exception as basic_error: @@ -733,7 +736,11 @@ class RemnaWaveService: panel_url = panel_user.get('subscriptionUrl', '') if not subscription.subscription_url or subscription.subscription_url != panel_url: subscription.subscription_url = panel_url - + + panel_happ_link = (panel_user.get('happ') or {}).get('cryptoLink') + if subscription.happ_crypto_link != panel_happ_link: + subscription.happ_crypto_link = panel_happ_link + active_squads = panel_user.get('activeInternalSquads', []) squad_uuids = [] if isinstance(active_squads, list): @@ -1113,6 +1120,7 @@ class RemnaWaveService: user.subscription.autopay_days_before = 3 user.subscription.remnawave_short_uuid = None user.subscription.subscription_url = "" + user.subscription.happ_crypto_link = None user.subscription.updated_at = datetime.utcnow() await db.commit() diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 7e25c427..9f88a285 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -130,7 +130,8 @@ class SubscriptionService: ) subscription.remnawave_short_uuid = updated_user.short_uuid - subscription.subscription_url = updated_user.subscription_url + subscription.subscription_url = updated_user.subscription_url + subscription.happ_crypto_link = updated_user.happ_crypto_link user.remnawave_uuid = updated_user.uuid await db.commit() @@ -190,6 +191,7 @@ class SubscriptionService: ) subscription.subscription_url = updated_user.subscription_url + subscription.happ_crypto_link = updated_user.happ_crypto_link await db.commit() status_text = "активным" if is_actually_active else "истёкшим" @@ -230,9 +232,10 @@ class SubscriptionService: async with self.api as api: updated_user = await api.revoke_user_subscription(user.remnawave_uuid) - + subscription.remnawave_short_uuid = updated_user.short_uuid subscription.subscription_url = updated_user.subscription_url + subscription.happ_crypto_link = updated_user.happ_crypto_link await db.commit() logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}") @@ -534,6 +537,7 @@ class SubscriptionService: subscription.remnawave_short_uuid = None subscription.subscription_url = "" + subscription.happ_crypto_link = None subscription.connected_squads = [] user.remnawave_uuid = None diff --git a/locales/en.json b/locales/en.json index bb1b7fc8..11393cbd 100644 --- a/locales/en.json +++ b/locales/en.json @@ -392,6 +392,7 @@ "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", + "SUBSCRIPTION_CONNECT_HAPP_MESSAGE": "📱 Connect Happ\n\n🚀 Tap the button below to open your subscription in the Happ app.", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", @@ -417,6 +418,17 @@ "SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option", "SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link", "SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.", + "HAPP_DOWNLOAD_BUTTON": "📥 Download Happ", + "HAPP_DOWNLOAD_SELECT_DEVICE": "📥 Download Happ\n\nChoose your device to get the store link:", + "HAPP_DOWNLOAD_DEVICE_IOS": "🍎 iOS", + "HAPP_DOWNLOAD_DEVICE_ANDROID": "🤖 Android", + "HAPP_DOWNLOAD_DEVICE_PC": "💻 PC", + "HAPP_DOWNLOAD_OPEN_LINK": "📥 Open link", + "HAPP_DOWNLOAD_BACK": "⬅️ Back", + "HAPP_DOWNLOAD_CLOSE": "❌ Close", + "HAPP_DOWNLOAD_DISABLED": "⚠️ App download is currently unavailable", + "HAPP_DOWNLOAD_LINK_NOT_AVAILABLE": "❌ Link for the selected platform is unavailable", + "HAPP_DOWNLOAD_LINK_PROMPT": "📥 Download Happ for {device_name}:", "REFERRAL_PROGRAM_TITLE": "👥 Referral program", "REFERRAL_STATS_HEADER": "📊 Your statistics:", "REFERRAL_STATS_INVITED": "• Invited users: {count}", diff --git a/locales/ru.json b/locales/ru.json index ff9fa404..874337ac 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -392,6 +392,7 @@ "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", + "SUBSCRIPTION_CONNECT_HAPP_MESSAGE": "📱 Подключить Happ\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в приложении Happ.", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", @@ -417,6 +418,17 @@ "SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"", "SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку", "SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", + "HAPP_DOWNLOAD_BUTTON": "📥 Скачать Happ", + "HAPP_DOWNLOAD_SELECT_DEVICE": "📥 Скачать Happ\n\nВыберите устройство, чтобы получить ссылку на приложение:", + "HAPP_DOWNLOAD_DEVICE_IOS": "🍎 iOS", + "HAPP_DOWNLOAD_DEVICE_ANDROID": "🤖 Android", + "HAPP_DOWNLOAD_DEVICE_PC": "💻 ПК", + "HAPP_DOWNLOAD_OPEN_LINK": "📥 Открыть ссылку", + "HAPP_DOWNLOAD_BACK": "⬅️ Назад", + "HAPP_DOWNLOAD_CLOSE": "❌ Закрыть", + "HAPP_DOWNLOAD_DISABLED": "⚠️ Загрузка приложения сейчас недоступна", + "HAPP_DOWNLOAD_LINK_NOT_AVAILABLE": "❌ Ссылка для выбранной платформы недоступна", + "HAPP_DOWNLOAD_LINK_PROMPT": "📥 Скачайте Happ для {device_name}:", "REFERRAL_PROGRAM_TITLE": "👥 Реферальная программа", "REFERRAL_STATS_HEADER": "📊 Ваша статистика:", "REFERRAL_STATS_INVITED": "• Приглашено пользователей: {count}", From b64bda64dc67ad0594ae04794f8630394747f43b Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 04:33:28 +0300 Subject: [PATCH 077/146] Revert "Add Happ cryptoLink connect mode with download flow" --- .env.example | 7 - README.md | 7 - app/config.py | 16 -- app/database/crud/subscription.py | 6 +- app/database/models.py | 3 +- app/database/universal_migration.py | 37 ----- app/external/remnawave_api.py | 4 +- app/handlers/subscription.py | 216 +++------------------------ app/keyboards/inline.py | 106 ++++--------- app/services/monitoring_service.py | 3 +- app/services/remnawave_service.py | 18 +-- app/services/subscription_service.py | 8 +- locales/en.json | 12 -- locales/ru.json | 12 -- 14 files changed, 58 insertions(+), 397 deletions(-) diff --git a/.env.example b/.env.example index 2aa0956e..41eb0876 100644 --- a/.env.example +++ b/.env.example @@ -280,18 +280,11 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) -# happ_cryptolink - открывает happ cryptoLink из панели (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= -# Кнопка скачивания Happ (активна только при CONNECT_BUTTON_MODE=happ_cryptolink) -HAPP_DOWNLOAD_BUTTON_ENABLED=false -HAPP_DOWNLOAD_LINK_IOS= -HAPP_DOWNLOAD_LINK_ANDROID= -HAPP_DOWNLOAD_LINK_PC= - # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода diff --git a/README.md b/README.md index 6ccd2b95..86b0f6dd 100644 --- a/README.md +++ b/README.md @@ -521,18 +521,11 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) -# happ_cryptolink - открывает happ cryptoLink из панели (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= -# Кнопка скачивания Happ (активна только при CONNECT_BUTTON_MODE=happ_cryptolink) -HAPP_DOWNLOAD_BUTTON_ENABLED=false -HAPP_DOWNLOAD_LINK_IOS= -HAPP_DOWNLOAD_LINK_ANDROID= -HAPP_DOWNLOAD_LINK_PC= - # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода diff --git a/app/config.py b/app/config.py index f7b7d795..a5bee373 100644 --- a/app/config.py +++ b/app/config.py @@ -209,10 +209,6 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" - HAPP_DOWNLOAD_BUTTON_ENABLED: bool = False - HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None - HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None - HAPP_DOWNLOAD_LINK_PC: Optional[str] = None HIDE_SUBSCRIPTION_LINK: bool = False ENABLE_LOGO_MODE: bool = True LOGO_FILE: str = "vpn_logo.png" @@ -547,18 +543,6 @@ class Settings(BaseSettings): def get_cryptobot_invoice_expires_seconds(self) -> int: return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600 - def is_happ_download_button_enabled(self) -> bool: - return self.HAPP_DOWNLOAD_BUTTON_ENABLED - - def get_happ_download_link(self, platform: str) -> Optional[str]: - platform_key = (platform or "").strip().lower() - links = { - "ios": self.HAPP_DOWNLOAD_LINK_IOS, - "android": self.HAPP_DOWNLOAD_LINK_ANDROID, - "pc": self.HAPP_DOWNLOAD_LINK_PC, - } - return links.get(platform_key) - def is_maintenance_mode(self) -> bool: return self.MAINTENANCE_MODE diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 2670227b..051c2369 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -965,8 +965,7 @@ async def create_subscription( device_limit: int = 1, connected_squads: list = None, remnawave_short_uuid: str = None, - subscription_url: str = "", - happ_crypto_link: str = None, + subscription_url: str = "" ) -> Subscription: if end_date is None: @@ -985,8 +984,7 @@ async def create_subscription( device_limit=device_limit, connected_squads=connected_squads, remnawave_short_uuid=remnawave_short_uuid, - subscription_url=subscription_url, - happ_crypto_link=happ_crypto_link + subscription_url=subscription_url ) db.add(subscription) diff --git a/app/database/models.py b/app/database/models.py index 36ad19db..0a19fe07 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -435,8 +435,7 @@ class Subscription(Base): traffic_used_gb = Column(Float, default=0.0) subscription_url = Column(String, nullable=True) - happ_crypto_link = Column(String, nullable=True) - + device_limit = Column(Integer, default=1) connected_squads = Column(JSON, default=list) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 15a462fd..eaa9d068 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1366,33 +1366,6 @@ async def add_ticket_sla_columns(): logger.error(f"Ошибка добавления SLA колонки в tickets: {e}") return False - -async def add_happ_crypto_link_column() -> bool: - logger.info("=== ДОБАВЛЕНИЕ ПОЛЯ HAPP_CRYPTO_LINK В SUBSCRIPTIONS ===") - - try: - column_exists = await check_column_exists('subscriptions', 'happ_crypto_link') - if column_exists: - logger.info("ℹ️ Поле happ_crypto_link уже существует") - return True - - async with engine.begin() as conn: - db_type = await get_database_type() - - if db_type in ('sqlite', 'postgresql', 'mysql'): - alter_sql = "ALTER TABLE subscriptions ADD COLUMN happ_crypto_link TEXT" - else: - logger.error(f"Неподдерживаемый тип БД для добавления поля: {db_type}") - return False - - await conn.execute(text(alter_sql)) - logger.info("✅ Поле happ_crypto_link успешно добавлено") - return True - - except Exception as e: - logger.error(f"Ошибка добавления поля happ_crypto_link: {e}") - return False - async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: @@ -1826,13 +1799,6 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением полей SLA в tickets") - logger.info("=== ДОБАВЛЕНИЕ ПОЛЯ HAPP_CRYPTO_LINK В SUBSCRIPTIONS ===") - happ_crypto_added = await add_happ_crypto_link_column() - if happ_crypto_added: - logger.info("✅ Поле happ_crypto_link в subscriptions готово") - else: - logger.warning("⚠️ Проблемы с добавлением поля happ_crypto_link в subscriptions") - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===") try: async with engine.begin() as conn: @@ -1989,7 +1955,6 @@ async def check_migration_status(): "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, "users_auto_promo_group_assigned_column": False, - "subscriptions_happ_crypto_link_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -2006,7 +1971,6 @@ async def check_migration_status(): status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') - status["subscriptions_happ_crypto_link_column"] = await check_column_exists('subscriptions', 'happ_crypto_link') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -2043,7 +2007,6 @@ async def check_migration_status(): "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", - "subscriptions_happ_crypto_link_column": "Поле happ_crypto_link в subscriptions", } for check_key, check_status in status.items(): diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index 21c58498..ec553efb 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -58,7 +58,6 @@ class RemnaWaveUser: ss_password: Optional[str] = None first_connected_at: Optional[datetime] = None last_triggered_threshold: int = 0 - happ_crypto_link: Optional[str] = None @dataclass @@ -613,8 +612,7 @@ class RemnaWaveAPI: vless_uuid=user_data.get('vlessUuid'), ss_password=user_data.get('ssPassword'), first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')), - last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0), - happ_crypto_link=(user_data.get('happ') or {}).get('cryptoLink') + last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0) ) def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]: diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index f1a26d14..4c0b14a2 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -41,9 +41,7 @@ from app.keyboards.inline import ( get_device_management_help_keyboard, get_payment_methods_keyboard_with_cart, get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart, - get_happ_download_device_keyboard, - get_happ_download_link_keyboard, + get_insufficient_balance_keyboard_with_cart ) from app.localization.texts import get_texts from app.services.remnawave_service import RemnaWaveService @@ -2639,10 +2637,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess countries_info = await _get_countries_info(subscription.connected_squads) countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет" - if settings.CONNECT_BUTTON_MODE == "happ_cryptolink": - subscription_url = getattr(subscription, 'happ_crypto_link', None) or "Генерируется..." - else: - subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..." + subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..." if subscription.is_trial: status_text = "🎁 Тестовая" @@ -4020,46 +4015,25 @@ async def handle_connect_subscription( ): texts = get_texts(db_user.language) subscription = db_user.subscription + + if not subscription or not subscription.subscription_url: + await callback.answer( + texts.t( + "SUBSCRIPTION_NO_ACTIVE_LINK", + "⚠ У вас нет активной подписки или ссылка еще генерируется", + ), + show_alert=True, + ) + return + connect_mode = settings.CONNECT_BUTTON_MODE - subscription_url = getattr(subscription, "subscription_url", None) if subscription else None - happ_crypto_link = getattr(subscription, "happ_crypto_link", None) if subscription else None - - if not subscription: - await callback.answer( - texts.t( - "SUBSCRIPTION_NO_ACTIVE_LINK", - "⚠ У вас нет активной подписки или ссылка еще генерируется", - ), - show_alert=True, - ) - return - - if connect_mode == "happ_cryptolink" and not happ_crypto_link: - await callback.answer( - texts.t( - "SUBSCRIPTION_NO_ACTIVE_LINK", - "⚠ У вас нет активной подписки или ссылка еще генерируется", - ), - show_alert=True, - ) - return - - if connect_mode != "happ_cryptolink" and not subscription_url: - await callback.answer( - texts.t( - "SUBSCRIPTION_NO_ACTIVE_LINK", - "⚠ У вас нет активной подписки или ссылка еще генерируется", - ), - show_alert=True, - ) - return if connect_mode == "miniapp_subscription": keyboard = InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription_url) + web_app=types.WebAppInfo(url=subscription.subscription_url) ) ], [ @@ -4117,7 +4091,7 @@ async def handle_connect_subscription( [ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription_url + url=subscription.subscription_url ) ], [ @@ -4136,41 +4110,6 @@ async def handle_connect_subscription( parse_mode="HTML" ) - elif connect_mode == "happ_cryptolink": - keyboard_rows = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=happ_crypto_link, - ) - ] - ] - - if settings.is_happ_download_button_enabled(): - keyboard_rows.append([ - InlineKeyboardButton( - text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), - callback_data="happ_download", - ) - ]) - - keyboard_rows.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) - - keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - - await callback.message.edit_text( - texts.t( - "SUBSCRIPTION_CONNECT_HAPP_MESSAGE", - """📱 Подключить Happ - -🚀 Нажмите кнопку ниже, чтобы открыть подписку в приложении Happ.""", - ), - reply_markup=keyboard, - parse_mode="HTML", - ) - else: device_text = texts.t( "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE", @@ -4180,111 +4119,14 @@ async def handle_connect_subscription( {subscription_url} 💡 Выберите ваше устройство для получения подробной инструкции по настройке:""", - ).format(subscription_url=subscription_url) + ).format(subscription_url=subscription.subscription_url) - await callback.message.edit_text( - device_text, - reply_markup=get_device_selection_keyboard(db_user.language), - parse_mode="HTML" - ) - - await callback.answer() - - -async def handle_happ_download_request( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - if not settings.is_happ_download_button_enabled(): - texts = get_texts(db_user.language) - await callback.answer( - texts.t("HAPP_DOWNLOAD_DISABLED", "⚠️ Загрузка приложения сейчас недоступна"), - show_alert=True, + await callback.message.edit_text( + device_text, + reply_markup=get_device_selection_keyboard(db_user.language), + parse_mode="HTML" ) - return - - texts = get_texts(db_user.language) - await callback.message.answer( - texts.t( - "HAPP_DOWNLOAD_SELECT_DEVICE", - "📥 Скачать Happ\n\nВыберите устройство, чтобы получить ссылку на приложение:", - ), - reply_markup=get_happ_download_device_keyboard(db_user.language), - parse_mode="HTML", - ) - await callback.answer() - - -async def handle_happ_download_device( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - if not settings.is_happ_download_button_enabled(): - await callback.answer() - return - - platform = callback.data.rsplit('_', maxsplit=1)[-1] - link = settings.get_happ_download_link(platform) - texts = get_texts(db_user.language) - - if not link: - await callback.answer( - texts.t("HAPP_DOWNLOAD_LINK_NOT_AVAILABLE", "❌ Ссылка для выбранной платформы недоступна"), - show_alert=True, - ) - return - - device_names = { - "ios": texts.t("HAPP_DOWNLOAD_DEVICE_IOS", "🍎 iOS"), - "android": texts.t("HAPP_DOWNLOAD_DEVICE_ANDROID", "🤖 Android"), - "pc": texts.t("HAPP_DOWNLOAD_DEVICE_PC", "💻 ПК"), - } - - message_text = texts.t( - "HAPP_DOWNLOAD_LINK_PROMPT", - "📥 Скачайте Happ для {device_name}:", - ).format(device_name=device_names.get(platform, platform.upper())) - - await callback.message.edit_text( - message_text, - reply_markup=get_happ_download_link_keyboard(link, db_user.language), - parse_mode="HTML", - ) - await callback.answer() - - -async def handle_happ_download_back( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - if not settings.is_happ_download_button_enabled(): - await callback.answer() - return - - texts = get_texts(db_user.language) - await callback.message.edit_text( - texts.t( - "HAPP_DOWNLOAD_SELECT_DEVICE", - "📥 Скачать Happ\n\nВыберите устройство, чтобы получить ссылку на приложение:", - ), - reply_markup=get_happ_download_device_keyboard(db_user.language), - parse_mode="HTML", - ) - await callback.answer() - - -async def handle_happ_download_close( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - try: - await callback.message.delete() - except Exception: - pass + await callback.answer() @@ -5262,22 +5104,6 @@ def register_handlers(dp: Dispatcher): handle_connect_subscription, F.data == "subscription_connect" ) - dp.callback_query.register( - handle_happ_download_request, - F.data == "happ_download" - ) - dp.callback_query.register( - handle_happ_download_device, - F.data.startswith("happ_download_device_") - ) - dp.callback_query.register( - handle_happ_download_back, - F.data == "happ_download_back" - ) - dp.callback_query.register( - handle_happ_download_close, - F.data == "happ_download_close" - ) dp.callback_query.register( handle_device_guide, diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index da9313fb..c367b562 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -88,7 +88,6 @@ def get_main_menu_keyboard( if has_active_subscription and subscription_is_active: connect_mode = settings.CONNECT_BUTTON_MODE subscription_url = getattr(subscription, "subscription_url", None) - happ_crypto_link = getattr(subscription, "happ_crypto_link", None) def _fallback_connect_button() -> InlineKeyboardButton: return InlineKeyboardButton( @@ -123,23 +122,6 @@ def get_main_menu_keyboard( ]) else: keyboard.append([_fallback_connect_button()]) - elif connect_mode == "happ_cryptolink": - if happ_crypto_link: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=happ_crypto_link - ) - ]) - if settings.is_happ_download_button_enabled(): - keyboard.append([ - InlineKeyboardButton( - text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), - callback_data="happ_download", - ) - ]) - else: - keyboard.append([_fallback_connect_button()]) else: keyboard.append([_fallback_connect_button()]) @@ -341,48 +323,36 @@ def get_subscription_keyboard( keyboard = [] if has_subscription: - connect_mode = settings.CONNECT_BUTTON_MODE - subscription_url = getattr(subscription, "subscription_url", None) - happ_crypto_link = getattr(subscription, "happ_crypto_link", None) - - if connect_mode == "miniapp_subscription" and subscription_url: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription_url) - ) - ]) - elif connect_mode == "miniapp_custom": - if settings.MINIAPP_CUSTOM_URL: + if subscription and subscription.subscription_url: + connect_mode = settings.CONNECT_BUTTON_MODE + + if connect_mode == "miniapp_subscription": keyboard.append([ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) + web_app=types.WebAppInfo(url=subscription.subscription_url) ) ]) + elif connect_mode == "miniapp_custom": + if settings.MINIAPP_CUSTOM_URL: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) + ) + ]) + else: + keyboard.append([ + InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") + ]) + elif connect_mode == "link": + keyboard.append([ + InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url) + ]) else: keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") ]) - elif connect_mode == "link" and subscription_url: - keyboard.append([ - InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_url) - ]) - elif connect_mode == "happ_cryptolink" and happ_crypto_link: - keyboard.append([ - InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=happ_crypto_link) - ]) - if settings.is_happ_download_button_enabled(): - keyboard.append([ - InlineKeyboardButton( - text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), - callback_data="happ_download", - ) - ]) - else: - keyboard.append([ - InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") - ]) if not is_trial: keyboard.append([ @@ -1295,8 +1265,8 @@ def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKey def get_connection_guide_keyboard( - subscription_url: str, - app: dict, + subscription_url: str, + app: dict, language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: from app.handlers.subscription import create_deep_link @@ -1335,8 +1305,8 @@ def get_connection_guide_keyboard( def get_app_selection_keyboard( - device_type: str, - apps: list, + device_type: str, + apps: list, language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -1366,32 +1336,6 @@ def get_app_selection_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_happ_download_device_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: - texts = get_texts(language) - return InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_DEVICE_IOS", "🍎 iOS"), callback_data="happ_download_device_ios")], - [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_DEVICE_ANDROID", "🤖 Android"), callback_data="happ_download_device_android")], - [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_DEVICE_PC", "💻 ПК"), callback_data="happ_download_device_pc")], - [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_CLOSE", "❌ Закрыть"), callback_data="happ_download_close")], - ] - ) - - -def get_happ_download_link_keyboard( - link: str, - language: str = DEFAULT_LANGUAGE, -) -> InlineKeyboardMarkup: - texts = get_texts(language) - return InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_OPEN_LINK", "📥 Открыть ссылку"), url=link)], - [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_BACK", "⬅️ Назад"), callback_data="happ_download_back")], - [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_CLOSE", "❌ Закрыть"), callback_data="happ_download_close")], - ] - ) - - def get_specific_app_keyboard( subscription_url: str, app: dict, diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 3502b080..1f7680f5 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -277,9 +277,8 @@ class MonitoringService: ), active_internal_squads=subscription.connected_squads ) - + subscription.subscription_url = updated_user.subscription_url - subscription.happ_crypto_link = updated_user.happ_crypto_link await db.commit() status_text = "активным" if is_active else "истёкшим" diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index b3b0e11c..dd6ee50c 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -581,8 +581,7 @@ class RemnaWaveService: subscription.autopay_enabled = False subscription.remnawave_short_uuid = None subscription.subscription_url = "" - subscription.happ_crypto_link = None - + db_user.remnawave_uuid = None await db.commit() @@ -638,15 +637,14 @@ class RemnaWaveService: subscription_data = { 'user_id': user.id, 'status': status.value, - 'is_trial': False, + 'is_trial': False, 'end_date': expire_at, 'traffic_limit_gb': traffic_limit_gb, 'traffic_used_gb': traffic_used_gb, 'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1, 'connected_squads': squad_uuids, 'remnawave_short_uuid': panel_user.get('shortUuid'), - 'subscription_url': panel_user.get('subscriptionUrl', ''), - 'happ_crypto_link': (panel_user.get('happ') or {}).get('cryptoLink') + 'subscription_url': panel_user.get('subscriptionUrl', '') } subscription = await create_subscription(db, **subscription_data) @@ -669,8 +667,7 @@ class RemnaWaveService: device_limit=1, connected_squads=[], remnawave_short_uuid=panel_user.get('shortUuid'), - subscription_url=panel_user.get('subscriptionUrl', ''), - happ_crypto_link=(panel_user.get('happ') or {}).get('cryptoLink') + subscription_url=panel_user.get('subscriptionUrl', '') ) logger.info(f"✅ Создана базовая подписка для пользователя {user.telegram_id}") except Exception as basic_error: @@ -736,11 +733,7 @@ class RemnaWaveService: panel_url = panel_user.get('subscriptionUrl', '') if not subscription.subscription_url or subscription.subscription_url != panel_url: subscription.subscription_url = panel_url - - panel_happ_link = (panel_user.get('happ') or {}).get('cryptoLink') - if subscription.happ_crypto_link != panel_happ_link: - subscription.happ_crypto_link = panel_happ_link - + active_squads = panel_user.get('activeInternalSquads', []) squad_uuids = [] if isinstance(active_squads, list): @@ -1120,7 +1113,6 @@ class RemnaWaveService: user.subscription.autopay_days_before = 3 user.subscription.remnawave_short_uuid = None user.subscription.subscription_url = "" - user.subscription.happ_crypto_link = None user.subscription.updated_at = datetime.utcnow() await db.commit() diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 9f88a285..7e25c427 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -130,8 +130,7 @@ class SubscriptionService: ) subscription.remnawave_short_uuid = updated_user.short_uuid - subscription.subscription_url = updated_user.subscription_url - subscription.happ_crypto_link = updated_user.happ_crypto_link + subscription.subscription_url = updated_user.subscription_url user.remnawave_uuid = updated_user.uuid await db.commit() @@ -191,7 +190,6 @@ class SubscriptionService: ) subscription.subscription_url = updated_user.subscription_url - subscription.happ_crypto_link = updated_user.happ_crypto_link await db.commit() status_text = "активным" if is_actually_active else "истёкшим" @@ -232,10 +230,9 @@ class SubscriptionService: async with self.api as api: updated_user = await api.revoke_user_subscription(user.remnawave_uuid) - + subscription.remnawave_short_uuid = updated_user.short_uuid subscription.subscription_url = updated_user.subscription_url - subscription.happ_crypto_link = updated_user.happ_crypto_link await db.commit() logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}") @@ -537,7 +534,6 @@ class SubscriptionService: subscription.remnawave_short_uuid = None subscription.subscription_url = "" - subscription.happ_crypto_link = None subscription.connected_squads = [] user.remnawave_uuid = None diff --git a/locales/en.json b/locales/en.json index 11393cbd..bb1b7fc8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -392,7 +392,6 @@ "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", - "SUBSCRIPTION_CONNECT_HAPP_MESSAGE": "📱 Connect Happ\n\n🚀 Tap the button below to open your subscription in the Happ app.", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", @@ -418,17 +417,6 @@ "SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option", "SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link", "SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.", - "HAPP_DOWNLOAD_BUTTON": "📥 Download Happ", - "HAPP_DOWNLOAD_SELECT_DEVICE": "📥 Download Happ\n\nChoose your device to get the store link:", - "HAPP_DOWNLOAD_DEVICE_IOS": "🍎 iOS", - "HAPP_DOWNLOAD_DEVICE_ANDROID": "🤖 Android", - "HAPP_DOWNLOAD_DEVICE_PC": "💻 PC", - "HAPP_DOWNLOAD_OPEN_LINK": "📥 Open link", - "HAPP_DOWNLOAD_BACK": "⬅️ Back", - "HAPP_DOWNLOAD_CLOSE": "❌ Close", - "HAPP_DOWNLOAD_DISABLED": "⚠️ App download is currently unavailable", - "HAPP_DOWNLOAD_LINK_NOT_AVAILABLE": "❌ Link for the selected platform is unavailable", - "HAPP_DOWNLOAD_LINK_PROMPT": "📥 Download Happ for {device_name}:", "REFERRAL_PROGRAM_TITLE": "👥 Referral program", "REFERRAL_STATS_HEADER": "📊 Your statistics:", "REFERRAL_STATS_INVITED": "• Invited users: {count}", diff --git a/locales/ru.json b/locales/ru.json index 874337ac..ff9fa404 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -392,7 +392,6 @@ "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", - "SUBSCRIPTION_CONNECT_HAPP_MESSAGE": "📱 Подключить Happ\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в приложении Happ.", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", @@ -418,17 +417,6 @@ "SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"", "SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку", "SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", - "HAPP_DOWNLOAD_BUTTON": "📥 Скачать Happ", - "HAPP_DOWNLOAD_SELECT_DEVICE": "📥 Скачать Happ\n\nВыберите устройство, чтобы получить ссылку на приложение:", - "HAPP_DOWNLOAD_DEVICE_IOS": "🍎 iOS", - "HAPP_DOWNLOAD_DEVICE_ANDROID": "🤖 Android", - "HAPP_DOWNLOAD_DEVICE_PC": "💻 ПК", - "HAPP_DOWNLOAD_OPEN_LINK": "📥 Открыть ссылку", - "HAPP_DOWNLOAD_BACK": "⬅️ Назад", - "HAPP_DOWNLOAD_CLOSE": "❌ Закрыть", - "HAPP_DOWNLOAD_DISABLED": "⚠️ Загрузка приложения сейчас недоступна", - "HAPP_DOWNLOAD_LINK_NOT_AVAILABLE": "❌ Ссылка для выбранной платформы недоступна", - "HAPP_DOWNLOAD_LINK_PROMPT": "📥 Скачайте Happ для {device_name}:", "REFERRAL_PROGRAM_TITLE": "👥 Реферальная программа", "REFERRAL_STATS_HEADER": "📊 Ваша статистика:", "REFERRAL_STATS_INVITED": "• Приглашено пользователей: {count}", From ff71f4b637eedee2b4da354e313fa3a659cf6f49 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 08:50:22 +0300 Subject: [PATCH 078/146] Add Happ crypto link mode and download flow --- .env.example | 7 + README.md | 7 + app/config.py | 18 ++ app/database/crud/subscription.py | 6 +- app/database/models.py | 1 + app/database/universal_migration.py | 38 ++++ app/external/remnawave_api.py | 4 +- app/handlers/subscription.py | 256 ++++++++++++++++++++++++++- app/keyboards/inline.py | 139 ++++++++++++++- app/services/remnawave_service.py | 14 +- app/services/subscription_service.py | 11 +- locales/en.json | 12 ++ locales/ru.json | 12 ++ 13 files changed, 507 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index 41eb0876..e71c1656 100644 --- a/.env.example +++ b/.env.example @@ -280,11 +280,18 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) +# happ_cryptolink - открывает ссылку Happ из поля cryptoLink (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +# Кнопка скачивания приложения Happ (используется в режиме happ_cryptolink) +HAPP_DOWNLOAD_BUTTON_ENABLED=false +HAPP_IOS_APP_URL= +HAPP_ANDROID_APP_URL= +HAPP_DESKTOP_APP_URL= + # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода diff --git a/README.md b/README.md index 86b0f6dd..f274afcf 100644 --- a/README.md +++ b/README.md @@ -521,11 +521,18 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) +# happ_cryptolink - открывает ссылку Happ из поля cryptoLink (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +# Кнопка скачивания приложения Happ (используется в режиме happ_cryptolink) +HAPP_DOWNLOAD_BUTTON_ENABLED=false +HAPP_IOS_APP_URL= +HAPP_ANDROID_APP_URL= +HAPP_DESKTOP_APP_URL= + # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода diff --git a/app/config.py b/app/config.py index a5bee373..f85cf1e9 100644 --- a/app/config.py +++ b/app/config.py @@ -214,6 +214,10 @@ class Settings(BaseSettings): LOGO_FILE: str = "vpn_logo.png" SKIP_RULES_ACCEPT: bool = False SKIP_REFERRAL_CODE: bool = False + HAPP_DOWNLOAD_BUTTON_ENABLED: bool = False + HAPP_IOS_APP_URL: Optional[str] = None + HAPP_ANDROID_APP_URL: Optional[str] = None + HAPP_DESKTOP_APP_URL: Optional[str] = None DEFAULT_LANGUAGE: str = "ru" AVAILABLE_LANGUAGES: str = "ru,en" @@ -543,6 +547,20 @@ class Settings(BaseSettings): def get_cryptobot_invoice_expires_seconds(self) -> int: return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600 + def is_happ_download_button_enabled(self) -> bool: + if not self.HAPP_DOWNLOAD_BUTTON_ENABLED: + return False + + links = self.get_happ_download_links() + return any(link for link in links.values()) + + def get_happ_download_links(self) -> Dict[str, Optional[str]]: + return { + "ios": self.HAPP_IOS_APP_URL, + "android": self.HAPP_ANDROID_APP_URL, + "desktop": self.HAPP_DESKTOP_APP_URL, + } + def is_maintenance_mode(self) -> bool: return self.MAINTENANCE_MODE diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 051c2369..40300c42 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -965,7 +965,8 @@ async def create_subscription( device_limit: int = 1, connected_squads: list = None, remnawave_short_uuid: str = None, - subscription_url: str = "" + subscription_url: str = "", + happ_crypto_link: Optional[str] = None, ) -> Subscription: if end_date is None: @@ -984,7 +985,8 @@ async def create_subscription( device_limit=device_limit, connected_squads=connected_squads, remnawave_short_uuid=remnawave_short_uuid, - subscription_url=subscription_url + subscription_url=subscription_url, + happ_crypto_link=happ_crypto_link, ) db.add(subscription) diff --git a/app/database/models.py b/app/database/models.py index 0a19fe07..6315ea6e 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -435,6 +435,7 @@ class Subscription(Base): traffic_used_gb = Column(Float, default=0.0) subscription_url = Column(String, nullable=True) + happ_crypto_link = Column(String, nullable=True) device_limit = Column(Integer, default=1) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index eaa9d068..77d6441d 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1459,6 +1459,35 @@ async def add_referral_system_columns(): logger.error(f"Ошибка миграции реферальной системы: {e}") return False + +async def add_happ_crypto_link_column(): + logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ HAPP_CRYPTO_LINK В SUBSCRIPTIONS ===") + + try: + async with engine.begin() as conn: + column_exists = await check_column_exists('subscriptions', 'happ_crypto_link') + + if column_exists: + logger.info("Колонка happ_crypto_link уже существует") + return True + + db_type = await get_database_type() + + if db_type == 'sqlite': + column_def = 'TEXT' + elif db_type == 'mysql': + column_def = 'TEXT' + else: + column_def = 'TEXT' + + await conn.execute(text(f"ALTER TABLE subscriptions ADD COLUMN happ_crypto_link {column_def}")) + logger.info("Колонка happ_crypto_link успешно добавлена") + return True + + except Exception as e: + logger.error(f"Ошибка добавления колонки happ_crypto_link: {e}") + return False + async def create_subscription_conversions_table(): table_exists = await check_table_exists('subscription_conversions') if table_exists: @@ -1729,6 +1758,12 @@ async def run_universal_migration(): referral_migration_success = await add_referral_system_columns() if not referral_migration_success: logger.warning("⚠️ Проблемы с миграцией реферальной системы") + + happ_column_added = await add_happ_crypto_link_column() + if happ_column_added: + logger.info("✅ Колонка happ_crypto_link готова") + else: + logger.warning("⚠️ Не удалось добавить колонку happ_crypto_link") logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===") cryptobot_created = await create_cryptobot_payments_table() @@ -1955,6 +1990,7 @@ async def check_migration_status(): "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, "users_auto_promo_group_assigned_column": False, + "happ_crypto_link_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1971,6 +2007,7 @@ async def check_migration_status(): status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') + status["happ_crypto_link_column"] = await check_column_exists('subscriptions', 'happ_crypto_link') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -2007,6 +2044,7 @@ async def check_migration_status(): "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", + "happ_crypto_link_column": "Колонка happ_crypto_link в subscriptions", } for check_key, check_status in status.items(): diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index ec553efb..aa616b9d 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -35,7 +35,7 @@ class RemnaWaveUser: username: str status: UserStatus used_traffic_bytes: int - lifetime_used_traffic_bytes: int + lifetime_used_traffic_bytes: int traffic_limit_bytes: int traffic_limit_strategy: TrafficLimitStrategy expire_at: datetime @@ -48,6 +48,7 @@ class RemnaWaveUser: active_internal_squads: List[Dict[str, str]] created_at: datetime updated_at: datetime + happ: Optional[Dict[str, str]] = None sub_last_user_agent: Optional[str] = None sub_last_opened_at: Optional[datetime] = None online_at: Optional[datetime] = None @@ -603,6 +604,7 @@ class RemnaWaveAPI: active_internal_squads=user_data['activeInternalSquads'], created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')), updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')), + happ=user_data.get('happ'), sub_last_user_agent=user_data.get('subLastUserAgent'), sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')), online_at=self._parse_optional_datetime(user_data.get('onlineAt')), diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 4c0b14a2..5fe80b9b 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -41,7 +41,9 @@ from app.keyboards.inline import ( get_device_management_help_keyboard, get_payment_methods_keyboard_with_cart, get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart + get_insufficient_balance_keyboard_with_cart, + get_happ_download_device_keyboard, + get_happ_download_link_keyboard, ) from app.localization.texts import get_texts from app.services.remnawave_service import RemnaWaveService @@ -882,6 +884,40 @@ async def activate_trial( [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) + elif connect_mode == "happ_cryptolink": + happ_link = getattr(subscription, "happ_crypto_link", None) + if not happ_link and remnawave_user and getattr(remnawave_user, "happ", None): + happ_link = (remnawave_user.happ or {}).get("cryptoLink") + + rows = [] + if happ_link: + rows.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=happ_link, + ) + ]) + else: + rows.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect", + ) + ]) + + if settings.is_happ_download_button_enabled(): + rows.append([ + InlineKeyboardButton( + text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), + callback_data="happ_download_app", + ) + ]) + + rows.append([ + InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu") + ]) + + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -3328,6 +3364,40 @@ async def confirm_purchase( [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) + elif connect_mode == "happ_cryptolink": + happ_link = getattr(subscription, "happ_crypto_link", None) + if not happ_link and remnawave_user and getattr(remnawave_user, "happ", None): + happ_link = (remnawave_user.happ or {}).get("cryptoLink") + + rows = [] + if happ_link: + rows.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=happ_link, + ) + ]) + else: + rows.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect", + ) + ]) + + if settings.is_happ_download_button_enabled(): + rows.append([ + InlineKeyboardButton( + text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), + callback_data="happ_download_app", + ) + ]) + + rows.append([ + InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu") + ]) + + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -4110,6 +4180,75 @@ async def handle_connect_subscription( parse_mode="HTML" ) + elif connect_mode == "happ_cryptolink": + crypto_link = getattr(subscription, "happ_crypto_link", None) + + if not crypto_link and subscription.remnawave_short_uuid: + subscription_service = SubscriptionService() + info = await subscription_service.get_subscription_info(subscription.remnawave_short_uuid) + updated = False + + if info: + new_crypto_link = (info.get("happ") or {}).get("cryptoLink") + if new_crypto_link and new_crypto_link != subscription.happ_crypto_link: + subscription.happ_crypto_link = new_crypto_link + crypto_link = new_crypto_link + updated = True + + panel_url = info.get("subscription_url") or info.get("subscriptionUrl") + if panel_url and panel_url != subscription.subscription_url: + subscription.subscription_url = panel_url + updated = True + + if updated: + await db.commit() + await db.refresh(subscription) + + if not crypto_link: + crypto_link = getattr(subscription, "happ_crypto_link", None) + + if not crypto_link: + await callback.answer( + texts.t( + "HAPP_CRYPTO_LINK_UNAVAILABLE", + "⚠️ Ссылка Happ пока недоступна. Попробуйте позже.", + ), + show_alert=True, + ) + return + + keyboard_rows = [[ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=crypto_link, + ) + ]] + + if settings.is_happ_download_button_enabled(): + keyboard_rows.append([ + InlineKeyboardButton( + text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), + callback_data="happ_download_app", + ) + ]) + + keyboard_rows.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + + await callback.message.edit_text( + texts.t( + "HAPP_CRYPTO_CONNECT_MESSAGE", + """🚀 Подключить Happ + +🔗 Нажмите кнопку ниже, чтобы открыть ссылку Happ:""", + ), + reply_markup=keyboard, + parse_mode="HTML", + ) + else: device_text = texts.t( "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE", @@ -4130,6 +4269,84 @@ async def handle_connect_subscription( await callback.answer() +async def show_happ_download_options( + callback: types.CallbackQuery, + db_user: User, + _: AsyncSession, +): + texts = get_texts(db_user.language) + + if not settings.is_happ_download_button_enabled(): + await callback.answer( + texts.t( + "HAPP_DOWNLOAD_NOT_AVAILABLE", + "⚠️ Ссылки для скачивания Happ не настроены.", + ), + show_alert=True, + ) + return + + links = settings.get_happ_download_links() + if not any(links.values()): + await callback.answer( + texts.t( + "HAPP_DOWNLOAD_NOT_AVAILABLE", + "⚠️ Ссылки для скачивания Happ не настроены.", + ), + show_alert=True, + ) + return + + await callback.message.edit_text( + texts.t( + "HAPP_DOWNLOAD_SELECT_DEVICE", + """📥 Скачать Happ + +Выберите устройство, для которого нужно скачать приложение:""", + ), + reply_markup=get_happ_download_device_keyboard(db_user.language), + parse_mode="HTML", + ) + + await callback.answer() + + +async def show_happ_download_link( + callback: types.CallbackQuery, + db_user: User, + _: AsyncSession, +): + platform = callback.data.split("_")[-1] + texts = get_texts(db_user.language) + links = settings.get_happ_download_links() + link = links.get(platform) + + if not link: + await callback.answer( + texts.t( + "HAPP_DOWNLOAD_LINK_MISSING", + "⚠️ Ссылка для выбранной платформы недоступна.", + ), + show_alert=True, + ) + return + + device_name = get_happ_platform_name(platform, db_user.language) + + await callback.message.edit_text( + texts.t( + "HAPP_DOWNLOAD_LINK_MESSAGE", + """📥 Скачать Happ + +Нажмите кнопку ниже, чтобы скачать приложение для {device_name}.""", + ).format(device_name=device_name), + reply_markup=get_happ_download_link_keyboard(platform, db_user.language), + parse_mode="HTML", + ) + + await callback.answer() + + async def claim_discount_offer( callback: types.CallbackQuery, db_user: User, @@ -4482,7 +4699,8 @@ def get_device_name(device_type: str, language: str = "ru") -> str: 'android': 'Android', 'windows': 'Windows', 'mac': 'macOS', - 'tv': 'Android TV' + 'tv': 'Android TV', + 'desktop': 'PC', } else: names = { @@ -4490,12 +4708,30 @@ def get_device_name(device_type: str, language: str = "ru") -> str: 'android': 'Android', 'windows': 'Windows', 'mac': 'macOS', - 'tv': 'Android TV' + 'tv': 'Android TV', + 'desktop': 'ПК', } - + return names.get(device_type, device_type) +def get_happ_platform_name(platform: str, language: str = "ru") -> str: + if language == "en": + names = { + 'ios': 'iPhone/iPad', + 'android': 'Android', + 'desktop': 'PC', + } + else: + names = { + 'ios': 'iPhone/iPad', + 'android': 'Android', + 'desktop': 'ПК', + } + + return names.get(platform, platform) + + def create_deep_link(app: Dict[str, Any], subscription_url: str) -> str: from app.config import settings @@ -5104,7 +5340,17 @@ def register_handlers(dp: Dispatcher): handle_connect_subscription, F.data == "subscription_connect" ) - + + dp.callback_query.register( + show_happ_download_options, + F.data == "happ_download_app" + ) + + dp.callback_query.register( + show_happ_download_link, + F.data.startswith("happ_download_platform_") + ) + dp.callback_query.register( handle_device_guide, F.data.startswith("device_guide_") diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index c367b562..722254d6 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -88,6 +88,7 @@ def get_main_menu_keyboard( if has_active_subscription and subscription_is_active: connect_mode = settings.CONNECT_BUTTON_MODE subscription_url = getattr(subscription, "subscription_url", None) + happ_crypto_link = getattr(subscription, "happ_crypto_link", None) def _fallback_connect_button() -> InlineKeyboardButton: return InlineKeyboardButton( @@ -122,9 +123,34 @@ def get_main_menu_keyboard( ]) else: keyboard.append([_fallback_connect_button()]) + elif connect_mode == "happ_cryptolink": + if happ_crypto_link: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=happ_crypto_link + ) + ]) + elif subscription_url: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=subscription_url + ) + ]) + else: + keyboard.append([_fallback_connect_button()]) else: keyboard.append([_fallback_connect_button()]) + if settings.CONNECT_BUTTON_MODE == "happ_cryptolink" and settings.is_happ_download_button_enabled(): + keyboard.append([ + InlineKeyboardButton( + text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), + callback_data="happ_download_app", + ) + ]) + keyboard.append([ InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"), InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription") @@ -349,6 +375,31 @@ def get_subscription_keyboard( keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url) ]) + elif connect_mode == "happ_cryptolink": + happ_link = getattr(subscription, "happ_crypto_link", None) + + if happ_link: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=happ_link + ) + ]) + else: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect" + ) + ]) + + if settings.is_happ_download_button_enabled(): + keyboard.append([ + InlineKeyboardButton( + text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), + callback_data="happ_download_app" + ) + ]) else: keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") @@ -1237,7 +1288,7 @@ def get_manage_countries_keyboard( def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: from app.config import settings texts = get_texts(language) - + keyboard = [ [ InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_IOS", "📱 iOS (iPhone/iPad)"), callback_data="device_guide_ios"), @@ -1265,7 +1316,7 @@ def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKey def get_connection_guide_keyboard( - subscription_url: str, + subscription_url: str, app: dict, language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: @@ -1304,6 +1355,90 @@ def get_connection_guide_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) +def get_happ_download_device_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: + texts = get_texts(language) + links = settings.get_happ_download_links() + + buttons: List[List[InlineKeyboardButton]] = [] + platform_buttons: List[InlineKeyboardButton] = [] + + if links.get("ios"): + platform_buttons.append( + InlineKeyboardButton( + text=texts.t("HAPP_DOWNLOAD_IOS", "🍏 iOS"), + callback_data="happ_download_platform_ios", + ) + ) + + if links.get("android"): + platform_buttons.append( + InlineKeyboardButton( + text=texts.t("HAPP_DOWNLOAD_ANDROID", "🤖 Android"), + callback_data="happ_download_platform_android", + ) + ) + + if platform_buttons: + if len(platform_buttons) > 1: + buttons.append(platform_buttons[:2]) + else: + buttons.append([platform_buttons[0]]) + + if len(platform_buttons) > 2: + buttons.append(platform_buttons[2:]) + + if links.get("desktop"): + buttons.append([ + InlineKeyboardButton( + text=texts.t("HAPP_DOWNLOAD_DESKTOP", "💻 ПК"), + callback_data="happ_download_platform_desktop", + ) + ]) + + buttons.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_SUBSCRIPTION", "⬅️ К подписке"), + callback_data="subscription_connect", + ) + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_happ_download_link_keyboard( + platform: str, + language: str = DEFAULT_LANGUAGE, +) -> InlineKeyboardMarkup: + texts = get_texts(language) + links = settings.get_happ_download_links() + keyboard: List[List[InlineKeyboardButton]] = [] + + link = links.get(platform) + if link: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("HAPP_DOWNLOAD_OPEN", "📥 Скачать приложение"), + url=link, + ) + ]) + + keyboard.append([ + InlineKeyboardButton( + text=texts.t("HAPP_DOWNLOAD_CHOOSE_DEVICE", "📱 Выбрать устройство"), + callback_data="happ_download_app", + ) + ]) + + keyboard.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_SUBSCRIPTION", "⬅️ К подписке"), + callback_data="subscription_connect", + ) + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + def get_app_selection_keyboard( device_type: str, apps: list, diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index dd6ee50c..d7672aaf 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -637,14 +637,15 @@ class RemnaWaveService: subscription_data = { 'user_id': user.id, 'status': status.value, - 'is_trial': False, + 'is_trial': False, 'end_date': expire_at, 'traffic_limit_gb': traffic_limit_gb, 'traffic_used_gb': traffic_used_gb, 'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1, 'connected_squads': squad_uuids, 'remnawave_short_uuid': panel_user.get('shortUuid'), - 'subscription_url': panel_user.get('subscriptionUrl', '') + 'subscription_url': panel_user.get('subscriptionUrl', ''), + 'happ_crypto_link': (panel_user.get('happ') or {}).get('cryptoLink'), } subscription = await create_subscription(db, **subscription_data) @@ -667,7 +668,8 @@ class RemnaWaveService: device_limit=1, connected_squads=[], remnawave_short_uuid=panel_user.get('shortUuid'), - subscription_url=panel_user.get('subscriptionUrl', '') + subscription_url=panel_user.get('subscriptionUrl', ''), + happ_crypto_link=(panel_user.get('happ') or {}).get('cryptoLink'), ) logger.info(f"✅ Создана базовая подписка для пользователя {user.telegram_id}") except Exception as basic_error: @@ -733,7 +735,11 @@ class RemnaWaveService: panel_url = panel_user.get('subscriptionUrl', '') if not subscription.subscription_url or subscription.subscription_url != panel_url: subscription.subscription_url = panel_url - + + happ_crypto_link = (panel_user.get('happ') or {}).get('cryptoLink') + if subscription.happ_crypto_link != happ_crypto_link: + subscription.happ_crypto_link = happ_crypto_link + active_squads = panel_user.get('activeInternalSquads', []) squad_uuids = [] if isinstance(active_squads, list): diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 7e25c427..5198c8a3 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -130,9 +130,10 @@ class SubscriptionService: ) subscription.remnawave_short_uuid = updated_user.short_uuid - subscription.subscription_url = updated_user.subscription_url + subscription.subscription_url = updated_user.subscription_url + subscription.happ_crypto_link = (updated_user.happ or {}).get('cryptoLink') user.remnawave_uuid = updated_user.uuid - + await db.commit() logger.info(f"✅ Создан/обновлен RemnaWave пользователь для подписки {subscription.id}") @@ -188,8 +189,9 @@ class SubscriptionService: ), active_internal_squads=subscription.connected_squads ) - + subscription.subscription_url = updated_user.subscription_url + subscription.happ_crypto_link = (updated_user.happ or {}).get('cryptoLink') await db.commit() status_text = "активным" if is_actually_active else "истёкшим" @@ -230,9 +232,10 @@ class SubscriptionService: async with self.api as api: updated_user = await api.revoke_user_subscription(user.remnawave_uuid) - + subscription.remnawave_short_uuid = updated_user.short_uuid subscription.subscription_url = updated_user.subscription_url + subscription.happ_crypto_link = (updated_user.happ or {}).get('cryptoLink') await db.commit() logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}") diff --git a/locales/en.json b/locales/en.json index bb1b7fc8..865c1d66 100644 --- a/locales/en.json +++ b/locales/en.json @@ -392,8 +392,10 @@ "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", + "HAPP_CRYPTO_CONNECT_MESSAGE": "🚀 Connect Happ\n\n🔗 Tap the button below to open your Happ link:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", + "HAPP_CRYPTO_LINK_UNAVAILABLE": "⚠️ Happ link is not available yet. Please try again later.", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", @@ -406,6 +408,16 @@ "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)", "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link", "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server", + "HAPP_DOWNLOAD_BUTTON": "📥 Download Happ", + "HAPP_DOWNLOAD_NOT_AVAILABLE": "⚠️ Happ download links are not configured.", + "HAPP_DOWNLOAD_SELECT_DEVICE": "📥 Download Happ\n\nChoose your device to download the app:", + "HAPP_DOWNLOAD_IOS": "🍏 iOS", + "HAPP_DOWNLOAD_ANDROID": "🤖 Android", + "HAPP_DOWNLOAD_DESKTOP": "💻 Desktop", + "HAPP_DOWNLOAD_LINK_MISSING": "⚠️ The link for the selected platform is unavailable.", + "HAPP_DOWNLOAD_LINK_MESSAGE": "📥 Download Happ\n\nTap the button below to download the app for {device_name}.", + "HAPP_DOWNLOAD_OPEN": "📥 Open download page", + "HAPP_DOWNLOAD_CHOOSE_DEVICE": "📱 Choose another device", "SUBSCRIPTION_APPS_TITLE": "📱 Apps for {device_name}", "SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:", "SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found", diff --git a/locales/ru.json b/locales/ru.json index ff9fa404..5ce8a6fb 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -392,8 +392,10 @@ "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", + "HAPP_CRYPTO_CONNECT_MESSAGE": "🚀 Подключить Happ\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку Happ:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", + "HAPP_CRYPTO_LINK_UNAVAILABLE": "⚠️ Ссылка Happ пока недоступна. Попробуйте позже.", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", @@ -406,6 +408,16 @@ "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)", "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку", "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу", + "HAPP_DOWNLOAD_BUTTON": "📥 Скачать Happ", + "HAPP_DOWNLOAD_NOT_AVAILABLE": "⚠️ Ссылки для скачивания Happ не настроены.", + "HAPP_DOWNLOAD_SELECT_DEVICE": "📥 Скачать Happ\n\nВыберите устройство, для которого нужно скачать приложение:", + "HAPP_DOWNLOAD_IOS": "🍏 iOS", + "HAPP_DOWNLOAD_ANDROID": "🤖 Android", + "HAPP_DOWNLOAD_DESKTOP": "💻 ПК", + "HAPP_DOWNLOAD_LINK_MISSING": "⚠️ Ссылка для выбранной платформы недоступна.", + "HAPP_DOWNLOAD_LINK_MESSAGE": "📥 Скачать Happ\n\nНажмите кнопку ниже, чтобы скачать приложение для {device_name}.", + "HAPP_DOWNLOAD_OPEN": "📥 Скачать приложение", + "HAPP_DOWNLOAD_CHOOSE_DEVICE": "📱 Выбрать устройство", "SUBSCRIPTION_APPS_TITLE": "📱 Приложения для {device_name}", "SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:", "SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено", From 033230f73e50518aa8389db33b1a3ce14a36319b Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 08:51:34 +0300 Subject: [PATCH 079/146] Revert "Add Happ crypto link connection mode with download prompts" --- .env.example | 7 - README.md | 7 - app/config.py | 18 -- app/database/crud/subscription.py | 6 +- app/database/models.py | 1 - app/database/universal_migration.py | 38 ---- app/external/remnawave_api.py | 4 +- app/handlers/subscription.py | 256 +-------------------------- app/keyboards/inline.py | 139 +-------------- app/services/remnawave_service.py | 14 +- app/services/subscription_service.py | 11 +- locales/en.json | 12 -- locales/ru.json | 12 -- 13 files changed, 18 insertions(+), 507 deletions(-) diff --git a/.env.example b/.env.example index e71c1656..41eb0876 100644 --- a/.env.example +++ b/.env.example @@ -280,18 +280,11 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) -# happ_cryptolink - открывает ссылку Happ из поля cryptoLink (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= -# Кнопка скачивания приложения Happ (используется в режиме happ_cryptolink) -HAPP_DOWNLOAD_BUTTON_ENABLED=false -HAPP_IOS_APP_URL= -HAPP_ANDROID_APP_URL= -HAPP_DESKTOP_APP_URL= - # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода diff --git a/README.md b/README.md index f274afcf..86b0f6dd 100644 --- a/README.md +++ b/README.md @@ -521,18 +521,11 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) -# happ_cryptolink - открывает ссылку Happ из поля cryptoLink (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= -# Кнопка скачивания приложения Happ (используется в режиме happ_cryptolink) -HAPP_DOWNLOAD_BUTTON_ENABLED=false -HAPP_IOS_APP_URL= -HAPP_ANDROID_APP_URL= -HAPP_DESKTOP_APP_URL= - # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода diff --git a/app/config.py b/app/config.py index f85cf1e9..a5bee373 100644 --- a/app/config.py +++ b/app/config.py @@ -214,10 +214,6 @@ class Settings(BaseSettings): LOGO_FILE: str = "vpn_logo.png" SKIP_RULES_ACCEPT: bool = False SKIP_REFERRAL_CODE: bool = False - HAPP_DOWNLOAD_BUTTON_ENABLED: bool = False - HAPP_IOS_APP_URL: Optional[str] = None - HAPP_ANDROID_APP_URL: Optional[str] = None - HAPP_DESKTOP_APP_URL: Optional[str] = None DEFAULT_LANGUAGE: str = "ru" AVAILABLE_LANGUAGES: str = "ru,en" @@ -547,20 +543,6 @@ class Settings(BaseSettings): def get_cryptobot_invoice_expires_seconds(self) -> int: return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600 - def is_happ_download_button_enabled(self) -> bool: - if not self.HAPP_DOWNLOAD_BUTTON_ENABLED: - return False - - links = self.get_happ_download_links() - return any(link for link in links.values()) - - def get_happ_download_links(self) -> Dict[str, Optional[str]]: - return { - "ios": self.HAPP_IOS_APP_URL, - "android": self.HAPP_ANDROID_APP_URL, - "desktop": self.HAPP_DESKTOP_APP_URL, - } - def is_maintenance_mode(self) -> bool: return self.MAINTENANCE_MODE diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 40300c42..051c2369 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -965,8 +965,7 @@ async def create_subscription( device_limit: int = 1, connected_squads: list = None, remnawave_short_uuid: str = None, - subscription_url: str = "", - happ_crypto_link: Optional[str] = None, + subscription_url: str = "" ) -> Subscription: if end_date is None: @@ -985,8 +984,7 @@ async def create_subscription( device_limit=device_limit, connected_squads=connected_squads, remnawave_short_uuid=remnawave_short_uuid, - subscription_url=subscription_url, - happ_crypto_link=happ_crypto_link, + subscription_url=subscription_url ) db.add(subscription) diff --git a/app/database/models.py b/app/database/models.py index 6315ea6e..0a19fe07 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -435,7 +435,6 @@ class Subscription(Base): traffic_used_gb = Column(Float, default=0.0) subscription_url = Column(String, nullable=True) - happ_crypto_link = Column(String, nullable=True) device_limit = Column(Integer, default=1) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 77d6441d..eaa9d068 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1459,35 +1459,6 @@ async def add_referral_system_columns(): logger.error(f"Ошибка миграции реферальной системы: {e}") return False - -async def add_happ_crypto_link_column(): - logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ HAPP_CRYPTO_LINK В SUBSCRIPTIONS ===") - - try: - async with engine.begin() as conn: - column_exists = await check_column_exists('subscriptions', 'happ_crypto_link') - - if column_exists: - logger.info("Колонка happ_crypto_link уже существует") - return True - - db_type = await get_database_type() - - if db_type == 'sqlite': - column_def = 'TEXT' - elif db_type == 'mysql': - column_def = 'TEXT' - else: - column_def = 'TEXT' - - await conn.execute(text(f"ALTER TABLE subscriptions ADD COLUMN happ_crypto_link {column_def}")) - logger.info("Колонка happ_crypto_link успешно добавлена") - return True - - except Exception as e: - logger.error(f"Ошибка добавления колонки happ_crypto_link: {e}") - return False - async def create_subscription_conversions_table(): table_exists = await check_table_exists('subscription_conversions') if table_exists: @@ -1758,12 +1729,6 @@ async def run_universal_migration(): referral_migration_success = await add_referral_system_columns() if not referral_migration_success: logger.warning("⚠️ Проблемы с миграцией реферальной системы") - - happ_column_added = await add_happ_crypto_link_column() - if happ_column_added: - logger.info("✅ Колонка happ_crypto_link готова") - else: - logger.warning("⚠️ Не удалось добавить колонку happ_crypto_link") logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===") cryptobot_created = await create_cryptobot_payments_table() @@ -1990,7 +1955,6 @@ async def check_migration_status(): "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, "users_auto_promo_group_assigned_column": False, - "happ_crypto_link_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -2007,7 +1971,6 @@ async def check_migration_status(): status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') - status["happ_crypto_link_column"] = await check_column_exists('subscriptions', 'happ_crypto_link') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -2044,7 +2007,6 @@ async def check_migration_status(): "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", - "happ_crypto_link_column": "Колонка happ_crypto_link в subscriptions", } for check_key, check_status in status.items(): diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index aa616b9d..ec553efb 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -35,7 +35,7 @@ class RemnaWaveUser: username: str status: UserStatus used_traffic_bytes: int - lifetime_used_traffic_bytes: int + lifetime_used_traffic_bytes: int traffic_limit_bytes: int traffic_limit_strategy: TrafficLimitStrategy expire_at: datetime @@ -48,7 +48,6 @@ class RemnaWaveUser: active_internal_squads: List[Dict[str, str]] created_at: datetime updated_at: datetime - happ: Optional[Dict[str, str]] = None sub_last_user_agent: Optional[str] = None sub_last_opened_at: Optional[datetime] = None online_at: Optional[datetime] = None @@ -604,7 +603,6 @@ class RemnaWaveAPI: active_internal_squads=user_data['activeInternalSquads'], created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')), updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')), - happ=user_data.get('happ'), sub_last_user_agent=user_data.get('subLastUserAgent'), sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')), online_at=self._parse_optional_datetime(user_data.get('onlineAt')), diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 5fe80b9b..4c0b14a2 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -41,9 +41,7 @@ from app.keyboards.inline import ( get_device_management_help_keyboard, get_payment_methods_keyboard_with_cart, get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart, - get_happ_download_device_keyboard, - get_happ_download_link_keyboard, + get_insufficient_balance_keyboard_with_cart ) from app.localization.texts import get_texts from app.services.remnawave_service import RemnaWaveService @@ -884,40 +882,6 @@ async def activate_trial( [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode == "happ_cryptolink": - happ_link = getattr(subscription, "happ_crypto_link", None) - if not happ_link and remnawave_user and getattr(remnawave_user, "happ", None): - happ_link = (remnawave_user.happ or {}).get("cryptoLink") - - rows = [] - if happ_link: - rows.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=happ_link, - ) - ]) - else: - rows.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect", - ) - ]) - - if settings.is_happ_download_button_enabled(): - rows.append([ - InlineKeyboardButton( - text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), - callback_data="happ_download_app", - ) - ]) - - rows.append([ - InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu") - ]) - - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -3364,40 +3328,6 @@ async def confirm_purchase( [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode == "happ_cryptolink": - happ_link = getattr(subscription, "happ_crypto_link", None) - if not happ_link and remnawave_user and getattr(remnawave_user, "happ", None): - happ_link = (remnawave_user.happ or {}).get("cryptoLink") - - rows = [] - if happ_link: - rows.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=happ_link, - ) - ]) - else: - rows.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect", - ) - ]) - - if settings.is_happ_download_button_enabled(): - rows.append([ - InlineKeyboardButton( - text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), - callback_data="happ_download_app", - ) - ]) - - rows.append([ - InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu") - ]) - - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -4180,75 +4110,6 @@ async def handle_connect_subscription( parse_mode="HTML" ) - elif connect_mode == "happ_cryptolink": - crypto_link = getattr(subscription, "happ_crypto_link", None) - - if not crypto_link and subscription.remnawave_short_uuid: - subscription_service = SubscriptionService() - info = await subscription_service.get_subscription_info(subscription.remnawave_short_uuid) - updated = False - - if info: - new_crypto_link = (info.get("happ") or {}).get("cryptoLink") - if new_crypto_link and new_crypto_link != subscription.happ_crypto_link: - subscription.happ_crypto_link = new_crypto_link - crypto_link = new_crypto_link - updated = True - - panel_url = info.get("subscription_url") or info.get("subscriptionUrl") - if panel_url and panel_url != subscription.subscription_url: - subscription.subscription_url = panel_url - updated = True - - if updated: - await db.commit() - await db.refresh(subscription) - - if not crypto_link: - crypto_link = getattr(subscription, "happ_crypto_link", None) - - if not crypto_link: - await callback.answer( - texts.t( - "HAPP_CRYPTO_LINK_UNAVAILABLE", - "⚠️ Ссылка Happ пока недоступна. Попробуйте позже.", - ), - show_alert=True, - ) - return - - keyboard_rows = [[ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=crypto_link, - ) - ]] - - if settings.is_happ_download_button_enabled(): - keyboard_rows.append([ - InlineKeyboardButton( - text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), - callback_data="happ_download_app", - ) - ]) - - keyboard_rows.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) - - keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - - await callback.message.edit_text( - texts.t( - "HAPP_CRYPTO_CONNECT_MESSAGE", - """🚀 Подключить Happ - -🔗 Нажмите кнопку ниже, чтобы открыть ссылку Happ:""", - ), - reply_markup=keyboard, - parse_mode="HTML", - ) - else: device_text = texts.t( "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE", @@ -4269,84 +4130,6 @@ async def handle_connect_subscription( await callback.answer() -async def show_happ_download_options( - callback: types.CallbackQuery, - db_user: User, - _: AsyncSession, -): - texts = get_texts(db_user.language) - - if not settings.is_happ_download_button_enabled(): - await callback.answer( - texts.t( - "HAPP_DOWNLOAD_NOT_AVAILABLE", - "⚠️ Ссылки для скачивания Happ не настроены.", - ), - show_alert=True, - ) - return - - links = settings.get_happ_download_links() - if not any(links.values()): - await callback.answer( - texts.t( - "HAPP_DOWNLOAD_NOT_AVAILABLE", - "⚠️ Ссылки для скачивания Happ не настроены.", - ), - show_alert=True, - ) - return - - await callback.message.edit_text( - texts.t( - "HAPP_DOWNLOAD_SELECT_DEVICE", - """📥 Скачать Happ - -Выберите устройство, для которого нужно скачать приложение:""", - ), - reply_markup=get_happ_download_device_keyboard(db_user.language), - parse_mode="HTML", - ) - - await callback.answer() - - -async def show_happ_download_link( - callback: types.CallbackQuery, - db_user: User, - _: AsyncSession, -): - platform = callback.data.split("_")[-1] - texts = get_texts(db_user.language) - links = settings.get_happ_download_links() - link = links.get(platform) - - if not link: - await callback.answer( - texts.t( - "HAPP_DOWNLOAD_LINK_MISSING", - "⚠️ Ссылка для выбранной платформы недоступна.", - ), - show_alert=True, - ) - return - - device_name = get_happ_platform_name(platform, db_user.language) - - await callback.message.edit_text( - texts.t( - "HAPP_DOWNLOAD_LINK_MESSAGE", - """📥 Скачать Happ - -Нажмите кнопку ниже, чтобы скачать приложение для {device_name}.""", - ).format(device_name=device_name), - reply_markup=get_happ_download_link_keyboard(platform, db_user.language), - parse_mode="HTML", - ) - - await callback.answer() - - async def claim_discount_offer( callback: types.CallbackQuery, db_user: User, @@ -4699,8 +4482,7 @@ def get_device_name(device_type: str, language: str = "ru") -> str: 'android': 'Android', 'windows': 'Windows', 'mac': 'macOS', - 'tv': 'Android TV', - 'desktop': 'PC', + 'tv': 'Android TV' } else: names = { @@ -4708,30 +4490,12 @@ def get_device_name(device_type: str, language: str = "ru") -> str: 'android': 'Android', 'windows': 'Windows', 'mac': 'macOS', - 'tv': 'Android TV', - 'desktop': 'ПК', + 'tv': 'Android TV' } - + return names.get(device_type, device_type) -def get_happ_platform_name(platform: str, language: str = "ru") -> str: - if language == "en": - names = { - 'ios': 'iPhone/iPad', - 'android': 'Android', - 'desktop': 'PC', - } - else: - names = { - 'ios': 'iPhone/iPad', - 'android': 'Android', - 'desktop': 'ПК', - } - - return names.get(platform, platform) - - def create_deep_link(app: Dict[str, Any], subscription_url: str) -> str: from app.config import settings @@ -5340,17 +5104,7 @@ def register_handlers(dp: Dispatcher): handle_connect_subscription, F.data == "subscription_connect" ) - - dp.callback_query.register( - show_happ_download_options, - F.data == "happ_download_app" - ) - - dp.callback_query.register( - show_happ_download_link, - F.data.startswith("happ_download_platform_") - ) - + dp.callback_query.register( handle_device_guide, F.data.startswith("device_guide_") diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 722254d6..c367b562 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -88,7 +88,6 @@ def get_main_menu_keyboard( if has_active_subscription and subscription_is_active: connect_mode = settings.CONNECT_BUTTON_MODE subscription_url = getattr(subscription, "subscription_url", None) - happ_crypto_link = getattr(subscription, "happ_crypto_link", None) def _fallback_connect_button() -> InlineKeyboardButton: return InlineKeyboardButton( @@ -123,34 +122,9 @@ def get_main_menu_keyboard( ]) else: keyboard.append([_fallback_connect_button()]) - elif connect_mode == "happ_cryptolink": - if happ_crypto_link: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=happ_crypto_link - ) - ]) - elif subscription_url: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription_url - ) - ]) - else: - keyboard.append([_fallback_connect_button()]) else: keyboard.append([_fallback_connect_button()]) - if settings.CONNECT_BUTTON_MODE == "happ_cryptolink" and settings.is_happ_download_button_enabled(): - keyboard.append([ - InlineKeyboardButton( - text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), - callback_data="happ_download_app", - ) - ]) - keyboard.append([ InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"), InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription") @@ -375,31 +349,6 @@ def get_subscription_keyboard( keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url) ]) - elif connect_mode == "happ_cryptolink": - happ_link = getattr(subscription, "happ_crypto_link", None) - - if happ_link: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=happ_link - ) - ]) - else: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect" - ) - ]) - - if settings.is_happ_download_button_enabled(): - keyboard.append([ - InlineKeyboardButton( - text=texts.t("HAPP_DOWNLOAD_BUTTON", "📥 Скачать Happ"), - callback_data="happ_download_app" - ) - ]) else: keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") @@ -1288,7 +1237,7 @@ def get_manage_countries_keyboard( def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: from app.config import settings texts = get_texts(language) - + keyboard = [ [ InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_IOS", "📱 iOS (iPhone/iPad)"), callback_data="device_guide_ios"), @@ -1316,7 +1265,7 @@ def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKey def get_connection_guide_keyboard( - subscription_url: str, + subscription_url: str, app: dict, language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: @@ -1355,90 +1304,6 @@ def get_connection_guide_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_happ_download_device_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: - texts = get_texts(language) - links = settings.get_happ_download_links() - - buttons: List[List[InlineKeyboardButton]] = [] - platform_buttons: List[InlineKeyboardButton] = [] - - if links.get("ios"): - platform_buttons.append( - InlineKeyboardButton( - text=texts.t("HAPP_DOWNLOAD_IOS", "🍏 iOS"), - callback_data="happ_download_platform_ios", - ) - ) - - if links.get("android"): - platform_buttons.append( - InlineKeyboardButton( - text=texts.t("HAPP_DOWNLOAD_ANDROID", "🤖 Android"), - callback_data="happ_download_platform_android", - ) - ) - - if platform_buttons: - if len(platform_buttons) > 1: - buttons.append(platform_buttons[:2]) - else: - buttons.append([platform_buttons[0]]) - - if len(platform_buttons) > 2: - buttons.append(platform_buttons[2:]) - - if links.get("desktop"): - buttons.append([ - InlineKeyboardButton( - text=texts.t("HAPP_DOWNLOAD_DESKTOP", "💻 ПК"), - callback_data="happ_download_platform_desktop", - ) - ]) - - buttons.append([ - InlineKeyboardButton( - text=texts.t("BACK_TO_SUBSCRIPTION", "⬅️ К подписке"), - callback_data="subscription_connect", - ) - ]) - - return InlineKeyboardMarkup(inline_keyboard=buttons) - - -def get_happ_download_link_keyboard( - platform: str, - language: str = DEFAULT_LANGUAGE, -) -> InlineKeyboardMarkup: - texts = get_texts(language) - links = settings.get_happ_download_links() - keyboard: List[List[InlineKeyboardButton]] = [] - - link = links.get(platform) - if link: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("HAPP_DOWNLOAD_OPEN", "📥 Скачать приложение"), - url=link, - ) - ]) - - keyboard.append([ - InlineKeyboardButton( - text=texts.t("HAPP_DOWNLOAD_CHOOSE_DEVICE", "📱 Выбрать устройство"), - callback_data="happ_download_app", - ) - ]) - - keyboard.append([ - InlineKeyboardButton( - text=texts.t("BACK_TO_SUBSCRIPTION", "⬅️ К подписке"), - callback_data="subscription_connect", - ) - ]) - - return InlineKeyboardMarkup(inline_keyboard=keyboard) - - def get_app_selection_keyboard( device_type: str, apps: list, diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index d7672aaf..dd6ee50c 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -637,15 +637,14 @@ class RemnaWaveService: subscription_data = { 'user_id': user.id, 'status': status.value, - 'is_trial': False, + 'is_trial': False, 'end_date': expire_at, 'traffic_limit_gb': traffic_limit_gb, 'traffic_used_gb': traffic_used_gb, 'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1, 'connected_squads': squad_uuids, 'remnawave_short_uuid': panel_user.get('shortUuid'), - 'subscription_url': panel_user.get('subscriptionUrl', ''), - 'happ_crypto_link': (panel_user.get('happ') or {}).get('cryptoLink'), + 'subscription_url': panel_user.get('subscriptionUrl', '') } subscription = await create_subscription(db, **subscription_data) @@ -668,8 +667,7 @@ class RemnaWaveService: device_limit=1, connected_squads=[], remnawave_short_uuid=panel_user.get('shortUuid'), - subscription_url=panel_user.get('subscriptionUrl', ''), - happ_crypto_link=(panel_user.get('happ') or {}).get('cryptoLink'), + subscription_url=panel_user.get('subscriptionUrl', '') ) logger.info(f"✅ Создана базовая подписка для пользователя {user.telegram_id}") except Exception as basic_error: @@ -735,11 +733,7 @@ class RemnaWaveService: panel_url = panel_user.get('subscriptionUrl', '') if not subscription.subscription_url or subscription.subscription_url != panel_url: subscription.subscription_url = panel_url - - happ_crypto_link = (panel_user.get('happ') or {}).get('cryptoLink') - if subscription.happ_crypto_link != happ_crypto_link: - subscription.happ_crypto_link = happ_crypto_link - + active_squads = panel_user.get('activeInternalSquads', []) squad_uuids = [] if isinstance(active_squads, list): diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 5198c8a3..7e25c427 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -130,10 +130,9 @@ class SubscriptionService: ) subscription.remnawave_short_uuid = updated_user.short_uuid - subscription.subscription_url = updated_user.subscription_url - subscription.happ_crypto_link = (updated_user.happ or {}).get('cryptoLink') + subscription.subscription_url = updated_user.subscription_url user.remnawave_uuid = updated_user.uuid - + await db.commit() logger.info(f"✅ Создан/обновлен RemnaWave пользователь для подписки {subscription.id}") @@ -189,9 +188,8 @@ class SubscriptionService: ), active_internal_squads=subscription.connected_squads ) - + subscription.subscription_url = updated_user.subscription_url - subscription.happ_crypto_link = (updated_user.happ or {}).get('cryptoLink') await db.commit() status_text = "активным" if is_actually_active else "истёкшим" @@ -232,10 +230,9 @@ class SubscriptionService: async with self.api as api: updated_user = await api.revoke_user_subscription(user.remnawave_uuid) - + subscription.remnawave_short_uuid = updated_user.short_uuid subscription.subscription_url = updated_user.subscription_url - subscription.happ_crypto_link = (updated_user.happ or {}).get('cryptoLink') await db.commit() logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}") diff --git a/locales/en.json b/locales/en.json index 865c1d66..bb1b7fc8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -392,10 +392,8 @@ "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", - "HAPP_CRYPTO_CONNECT_MESSAGE": "🚀 Connect Happ\n\n🔗 Tap the button below to open your Happ link:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", - "HAPP_CRYPTO_LINK_UNAVAILABLE": "⚠️ Happ link is not available yet. Please try again later.", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", @@ -408,16 +406,6 @@ "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)", "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link", "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server", - "HAPP_DOWNLOAD_BUTTON": "📥 Download Happ", - "HAPP_DOWNLOAD_NOT_AVAILABLE": "⚠️ Happ download links are not configured.", - "HAPP_DOWNLOAD_SELECT_DEVICE": "📥 Download Happ\n\nChoose your device to download the app:", - "HAPP_DOWNLOAD_IOS": "🍏 iOS", - "HAPP_DOWNLOAD_ANDROID": "🤖 Android", - "HAPP_DOWNLOAD_DESKTOP": "💻 Desktop", - "HAPP_DOWNLOAD_LINK_MISSING": "⚠️ The link for the selected platform is unavailable.", - "HAPP_DOWNLOAD_LINK_MESSAGE": "📥 Download Happ\n\nTap the button below to download the app for {device_name}.", - "HAPP_DOWNLOAD_OPEN": "📥 Open download page", - "HAPP_DOWNLOAD_CHOOSE_DEVICE": "📱 Choose another device", "SUBSCRIPTION_APPS_TITLE": "📱 Apps for {device_name}", "SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:", "SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found", diff --git a/locales/ru.json b/locales/ru.json index 5ce8a6fb..ff9fa404 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -392,10 +392,8 @@ "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", - "HAPP_CRYPTO_CONNECT_MESSAGE": "🚀 Подключить Happ\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку Happ:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", - "HAPP_CRYPTO_LINK_UNAVAILABLE": "⚠️ Ссылка Happ пока недоступна. Попробуйте позже.", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", @@ -408,16 +406,6 @@ "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)", "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку", "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу", - "HAPP_DOWNLOAD_BUTTON": "📥 Скачать Happ", - "HAPP_DOWNLOAD_NOT_AVAILABLE": "⚠️ Ссылки для скачивания Happ не настроены.", - "HAPP_DOWNLOAD_SELECT_DEVICE": "📥 Скачать Happ\n\nВыберите устройство, для которого нужно скачать приложение:", - "HAPP_DOWNLOAD_IOS": "🍏 iOS", - "HAPP_DOWNLOAD_ANDROID": "🤖 Android", - "HAPP_DOWNLOAD_DESKTOP": "💻 ПК", - "HAPP_DOWNLOAD_LINK_MISSING": "⚠️ Ссылка для выбранной платформы недоступна.", - "HAPP_DOWNLOAD_LINK_MESSAGE": "📥 Скачать Happ\n\nНажмите кнопку ниже, чтобы скачать приложение для {device_name}.", - "HAPP_DOWNLOAD_OPEN": "📥 Скачать приложение", - "HAPP_DOWNLOAD_CHOOSE_DEVICE": "📱 Выбрать устройство", "SUBSCRIPTION_APPS_TITLE": "📱 Приложения для {device_name}", "SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:", "SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено", From b258bd95bd6319c46560207d99813a03bfd78867 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 10:25:06 +0300 Subject: [PATCH 080/146] Document happ cryptolink mode in env example --- .env.example | 7 + README.md | 7 + app/config.py | 20 +++ app/database/crud/subscription.py | 6 +- app/database/models.py | 3 +- app/database/universal_migration.py | 43 ++++++ app/external/remnawave_api.py | 20 ++- app/handlers/subscription.py | 217 ++++++++++++++++++++++----- app/keyboards/inline.py | 77 ++++++++-- app/localization/locales/en.json | 8 + app/localization/locales/ru.json | 8 + app/services/monitoring_service.py | 1 + app/services/remnawave_service.py | 25 ++- app/services/subscription_service.py | 6 +- app/utils/subscription_utils.py | 18 ++- locales/en.json | 8 + locales/ru.json | 8 + 17 files changed, 419 insertions(+), 63 deletions(-) diff --git a/.env.example b/.env.example index 41eb0876..1070f38a 100644 --- a/.env.example +++ b/.env.example @@ -280,11 +280,18 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) +# happ_cryptolink - открывает ссылку из поля cryptoLink (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +# Параметры режима happ_cryptolink +CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false +HAPP_DOWNLOAD_LINK_IOS= +HAPP_DOWNLOAD_LINK_ANDROID= +HAPP_DOWNLOAD_LINK_PC= + # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода diff --git a/README.md b/README.md index 86b0f6dd..a7efc850 100644 --- a/README.md +++ b/README.md @@ -521,11 +521,18 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) +# happ_cryptolink - открывает ссылку из поля cryptoLink (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +# Параметры режима happ_cryptolink +CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false +HAPP_DOWNLOAD_LINK_IOS= +HAPP_DOWNLOAD_LINK_ANDROID= +HAPP_DOWNLOAD_LINK_PC= + # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false # Пропустить запрос реферального кода diff --git a/app/config.py b/app/config.py index a5bee373..bace5055 100644 --- a/app/config.py +++ b/app/config.py @@ -209,6 +209,10 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" + CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False + HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None + HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None + HAPP_DOWNLOAD_LINK_PC: Optional[str] = None HIDE_SUBSCRIPTION_LINK: bool = False ENABLE_LOGO_MODE: bool = True LOGO_FILE: str = "vpn_logo.png" @@ -543,6 +547,22 @@ class Settings(BaseSettings): def get_cryptobot_invoice_expires_seconds(self) -> int: return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600 + def is_happ_cryptolink_mode(self) -> bool: + return self.CONNECT_BUTTON_MODE == "happ_cryptolink" + + def is_happ_download_button_enabled(self) -> bool: + return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED + + def get_happ_download_link(self, platform: str) -> Optional[str]: + platform_key = platform.lower() + links = { + "ios": (self.HAPP_DOWNLOAD_LINK_IOS or "").strip(), + "android": (self.HAPP_DOWNLOAD_LINK_ANDROID or "").strip(), + "pc": (self.HAPP_DOWNLOAD_LINK_PC or "").strip(), + } + link = links.get(platform_key) + return link if link else None + def is_maintenance_mode(self) -> bool: return self.MAINTENANCE_MODE diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 051c2369..91b79375 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -965,7 +965,8 @@ async def create_subscription( device_limit: int = 1, connected_squads: list = None, remnawave_short_uuid: str = None, - subscription_url: str = "" + subscription_url: str = "", + subscription_crypto_link: str = "" ) -> Subscription: if end_date is None: @@ -984,7 +985,8 @@ async def create_subscription( device_limit=device_limit, connected_squads=connected_squads, remnawave_short_uuid=remnawave_short_uuid, - subscription_url=subscription_url + subscription_url=subscription_url, + subscription_crypto_link=subscription_crypto_link ) db.add(subscription) diff --git a/app/database/models.py b/app/database/models.py index 0a19fe07..0a3ad865 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -435,7 +435,8 @@ class Subscription(Base): traffic_used_gb = Column(Float, default=0.0) subscription_url = Column(String, nullable=True) - + subscription_crypto_link = Column(String, nullable=True) + device_limit = Column(Integer, default=1) connected_squads = Column(JSON, default=list) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index eaa9d068..b123c750 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1366,6 +1366,39 @@ async def add_ticket_sla_columns(): logger.error(f"Ошибка добавления SLA колонки в tickets: {e}") return False + +async def add_subscription_crypto_link_column() -> bool: + column_exists = await check_column_exists('subscriptions', 'subscription_crypto_link') + if column_exists: + logger.info("ℹ️ Колонка subscription_crypto_link уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN subscription_crypto_link TEXT")) + elif db_type == 'postgresql': + await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN subscription_crypto_link VARCHAR")) + elif db_type == 'mysql': + await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN subscription_crypto_link VARCHAR(512)")) + else: + logger.error(f"Неподдерживаемый тип БД для добавления subscription_crypto_link: {db_type}") + return False + + await conn.execute(text( + "UPDATE subscriptions SET subscription_crypto_link = subscription_url " + "WHERE subscription_crypto_link IS NULL OR subscription_crypto_link = ''" + )) + + logger.info("✅ Добавлена колонка subscription_crypto_link в таблицу subscriptions") + return True + except Exception as e: + logger.error(f"Ошибка добавления колонки subscription_crypto_link: {e}") + return False + + async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: @@ -1799,6 +1832,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением полей SLA в tickets") + logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ CRYPTO LINK ДЛЯ ПОДПИСОК ===") + crypto_link_added = await add_subscription_crypto_link_column() + if crypto_link_added: + logger.info("✅ Колонка subscription_crypto_link готова") + else: + logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===") try: async with engine.begin() as conn: @@ -1955,6 +1995,7 @@ async def check_migration_status(): "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, "users_auto_promo_group_assigned_column": False, + "subscription_crypto_link_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1971,6 +2012,7 @@ async def check_migration_status(): status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') + status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -2007,6 +2049,7 @@ async def check_migration_status(): "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", + "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } for check_key, check_status in status.items(): diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index ec553efb..8949dad9 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -58,6 +58,8 @@ class RemnaWaveUser: ss_password: Optional[str] = None first_connected_at: Optional[datetime] = None last_triggered_threshold: int = 0 + happ_link: Optional[str] = None + happ_crypto_link: Optional[str] = None @dataclass @@ -92,6 +94,8 @@ class SubscriptionInfo: ss_conf_links: Dict[str, str] subscription_url: str happ: Optional[Dict[str, str]] + happ_link: Optional[str] = None + happ_crypto_link: Optional[str] = None class RemnaWaveAPIError(Exception): @@ -584,6 +588,10 @@ class RemnaWaveAPI: def _parse_user(self, user_data: Dict) -> RemnaWaveUser: + happ_data = user_data.get('happ') or {} + happ_link = happ_data.get('link') or happ_data.get('url') + happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link') + return RemnaWaveUser( uuid=user_data['uuid'], short_uuid=user_data['shortUuid'], @@ -612,7 +620,9 @@ class RemnaWaveAPI: vless_uuid=user_data.get('vlessUuid'), ss_password=user_data.get('ssPassword'), first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')), - last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0) + last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0), + happ_link=happ_link, + happ_crypto_link=happ_crypto_link ) def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]: @@ -645,13 +655,19 @@ class RemnaWaveAPI: ) def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo: + happ_data = data.get('happ') or {} + happ_link = happ_data.get('link') or happ_data.get('url') + happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link') + return SubscriptionInfo( is_found=data['isFound'], user=data.get('user'), links=data.get('links', []), ss_conf_links=data.get('ssConfLinks', {}), subscription_url=data.get('subscriptionUrl', ''), - happ=data.get('happ') + happ=data.get('happ'), + happ_link=happ_link, + happ_crypto_link=happ_crypto_link ) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 4c0b14a2..8cc84fd3 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -39,6 +39,8 @@ from app.keyboards.inline import ( get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, get_devices_management_keyboard, get_device_reset_confirm_keyboard, get_device_management_help_keyboard, + get_happ_download_platform_keyboard, get_happ_download_link_keyboard, + get_happ_download_button_row, get_payment_methods_keyboard_with_cart, get_subscription_confirm_keyboard_with_cart, get_insufficient_balance_keyboard_with_cart @@ -61,6 +63,7 @@ from app.utils.pricing_utils import ( format_period_description, ) from app.utils.pagination import paginate_list +from app.utils.subscription_utils import get_display_subscription_link logger = logging.getLogger(__name__) @@ -561,12 +564,13 @@ async def show_subscription_info( message += f"• {device_info}\n" message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "") - if hasattr(subscription, 'subscription_url') and subscription.subscription_url: + subscription_link = get_display_subscription_link(subscription) + if subscription_link: if actual_status in ['trial_active', 'paid_active'] and not settings.HIDE_SUBSCRIPTION_LINK: message += "\n\n" + texts.t( "SUBSCRIPTION_CONNECT_LINK_SECTION", "🔗 Ссылка для подключения:\n{subscription_url}", - ).format(subscription_url=subscription.subscription_url) + ).format(subscription_url=subscription_link) message += "\n\n" + texts.t( "SUBSCRIPTION_CONNECT_LINK_PROMPT", "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", @@ -833,11 +837,12 @@ async def activate_trial( except Exception as e: logger.error(f"Ошибка отправки уведомления о триале: {e}") - if remnawave_user and hasattr(subscription, 'subscription_url') and subscription.subscription_url: + subscription_link = get_display_subscription_link(subscription) + if remnawave_user and subscription_link: subscription_import_link = texts.t( "SUBSCRIPTION_IMPORT_LINK_SECTION", "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription.subscription_url) + ).format(subscription_url=subscription_link) trial_success_text = ( f"{texts.TRIAL_ACTIVATED}\n\n" @@ -852,7 +857,7 @@ async def activate_trial( [ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription.subscription_url), + web_app=types.WebAppInfo(url=subscription_link), ) ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], @@ -877,11 +882,20 @@ async def activate_trial( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode == "link": - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], + elif connect_mode in {"link", "happ_cryptolink"}: + rows = [ + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu" + ) ]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -3279,11 +3293,12 @@ async def confirm_purchase( await db.refresh(db_user) await db.refresh(subscription) - if remnawave_user and hasattr(subscription, 'subscription_url') and subscription.subscription_url: + subscription_link = get_display_subscription_link(subscription) + if remnawave_user and subscription_link: import_link_section = texts.t( "SUBSCRIPTION_IMPORT_LINK_SECTION", "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription.subscription_url) + ).format(subscription_url=subscription_link) success_text = ( f"{texts.SUBSCRIPTION_PURCHASED}\n\n" @@ -3298,7 +3313,7 @@ async def confirm_purchase( [ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription.subscription_url), + web_app=types.WebAppInfo(url=subscription_link), ) ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], @@ -3323,11 +3338,15 @@ async def confirm_purchase( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode == "link": - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], - ]) + elif connect_mode in {"link", "happ_cryptolink"}: + rows = [ + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -4005,9 +4024,88 @@ async def confirm_reset_devices( db_user: User, db: AsyncSession ): - + await handle_device_management(callback, db_user, db) +async def handle_happ_download_request( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + texts = get_texts(db_user.language) + prompt_text = texts.t( + "HAPP_DOWNLOAD_PROMPT", + "📥 Скачать Happ\nВыберите ваше устройство:", + ) + + keyboard = get_happ_download_platform_keyboard(db_user.language) + + await callback.message.answer(prompt_text, reply_markup=keyboard, parse_mode="HTML") + await callback.answer() + + +async def handle_happ_download_platform_choice( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + platform = callback.data.split('_')[-1] + texts = get_texts(db_user.language) + link = settings.get_happ_download_link(platform) + + if not link: + await callback.answer( + texts.t("HAPP_DOWNLOAD_LINK_NOT_SET", "❌ Ссылка для этого устройства не настроена"), + show_alert=True, + ) + return + + platform_names = { + "ios": texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), + "android": texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), + "pc": texts.t("HAPP_PLATFORM_PC", "💻 ПК"), + } + + link_text = texts.t( + "HAPP_DOWNLOAD_LINK_MESSAGE", + "⬇️ Скачайте Happ для {platform}:", + ).format(platform=platform_names.get(platform, platform.upper())) + + keyboard = get_happ_download_link_keyboard(db_user.language, link) + + await callback.message.edit_text(link_text, reply_markup=keyboard, parse_mode="HTML") + await callback.answer() + + +async def handle_happ_download_close( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + try: + await callback.message.delete() + except Exception: + pass + + await callback.answer() + + +async def handle_happ_download_back( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + texts = get_texts(db_user.language) + prompt_text = texts.t( + "HAPP_DOWNLOAD_PROMPT", + "📥 Скачать Happ\nВыберите ваше устройство:", + ) + + keyboard = get_happ_download_platform_keyboard(db_user.language) + + await callback.message.edit_text(prompt_text, reply_markup=keyboard, parse_mode="HTML") + await callback.answer() + async def handle_connect_subscription( callback: types.CallbackQuery, db_user: User, @@ -4015,8 +4113,9 @@ async def handle_connect_subscription( ): texts = get_texts(db_user.language) subscription = db_user.subscription - - if not subscription or not subscription.subscription_url: + subscription_link = get_display_subscription_link(subscription) + + if not subscription_link: await callback.answer( texts.t( "SUBSCRIPTION_NO_ACTIVE_LINK", @@ -4033,7 +4132,7 @@ async def handle_connect_subscription( [ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription.subscription_url) + web_app=types.WebAppInfo(url=subscription_link) ) ], [ @@ -4086,19 +4185,24 @@ async def handle_connect_subscription( parse_mode="HTML" ) - elif connect_mode == "link": - keyboard = InlineKeyboardMarkup(inline_keyboard=[ + elif connect_mode in {"link", "happ_cryptolink"}: + rows = [ [ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription.subscription_url + url=subscription_link ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") ]) + keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + await callback.message.edit_text( texts.t( "SUBSCRIPTION_CONNECT_LINK_MESSAGE", @@ -4119,7 +4223,7 @@ async def handle_connect_subscription( {subscription_url} 💡 Выберите ваше устройство для получения подробной инструкции по настройке:""", - ).format(subscription_url=subscription.subscription_url) + ).format(subscription_url=subscription_link) await callback.message.edit_text( device_text, @@ -4208,8 +4312,9 @@ async def handle_device_guide( device_type = callback.data.split('_')[2] texts = get_texts(db_user.language) subscription = db_user.subscription - - if not subscription or not subscription.subscription_url: + subscription_link = get_display_subscription_link(subscription) + + if not subscription_link: await callback.answer( texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"), show_alert=True, @@ -4217,6 +4322,13 @@ async def handle_device_guide( return apps = get_apps_for_device(device_type, db_user.language) + subscription_link = get_display_subscription_link(subscription) + if not subscription_link: + await callback.answer( + texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"), + show_alert=True, + ) + return if not apps: await callback.answer( @@ -4234,7 +4346,7 @@ async def handle_device_guide( ).format(device_name=get_device_name(device_type, db_user.language)) + "\n\n" + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + f"\n{subscription.subscription_url}\n\n" + + f"\n{subscription_link}\n\n" + texts.t( "SUBSCRIPTION_DEVICE_FEATURED_APP", "📋 Рекомендуемое приложение: {app_name}", @@ -4273,7 +4385,7 @@ async def handle_device_guide( await callback.message.edit_text( guide_text, reply_markup=get_connection_guide_keyboard( - subscription.subscription_url, + subscription_link, featured_app, db_user.language ), @@ -4343,7 +4455,7 @@ async def handle_specific_app_guide( ).format(app_name=app['name'], device_name=get_device_name(device_type, db_user.language)) + "\n\n" + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + f"\n{subscription.subscription_url}\n\n" + + f"\n{subscription_link}\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:") + f"\n{app['installationStep']['description'][db_user.language]}\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:") @@ -4366,7 +4478,7 @@ async def handle_specific_app_guide( await callback.message.edit_text( guide_text, reply_markup=get_specific_app_keyboard( - subscription.subscription_url, + subscription_link, app, device_type, db_user.language @@ -4391,9 +4503,11 @@ async def handle_open_subscription_link( db_user: User, db: AsyncSession ): + texts = get_texts(db_user.language) subscription = db_user.subscription - - if not subscription or not subscription.subscription_url: + subscription_link = get_display_subscription_link(subscription) + + if not subscription_link: await callback.answer( texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"), show_alert=True, @@ -4403,7 +4517,7 @@ async def handle_open_subscription_link( link_text = ( texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + "\n\n" - + f"{subscription.subscription_url}\n\n" + + f"{subscription_link}\n\n" + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:") + "\n" + "\n".join( @@ -4530,11 +4644,12 @@ async def show_device_connection_help( ): subscription = db_user.subscription - - if not subscription or not subscription.subscription_url: + subscription_link = get_display_subscription_link(subscription) + + if not subscription_link: await callback.answer("❌ Ссылка подписки недоступна", show_alert=True) return - + help_text = f""" 📱 Как подключить устройство заново @@ -4553,7 +4668,7 @@ async def show_device_connection_help( • Нажмите "Подключить" 🔗 Ваша ссылка подписки: -{subscription.subscription_url} +{subscription_link} 💡 Совет: Сохраните эту ссылку - она понадобится для подключения новых устройств """ @@ -5100,6 +5215,26 @@ def register_handlers(dp: Dispatcher): F.data.startswith("claim_discount_") ) + dp.callback_query.register( + handle_happ_download_request, + F.data == "subscription_happ_download" + ) + + dp.callback_query.register( + handle_happ_download_platform_choice, + F.data.in_(["happ_download_ios", "happ_download_android", "happ_download_pc"]) + ) + + dp.callback_query.register( + handle_happ_download_close, + F.data == "happ_download_close" + ) + + dp.callback_query.register( + handle_happ_download_back, + F.data == "happ_download_back" + ) + dp.callback_query.register( handle_connect_subscription, F.data == "subscription_connect" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index c367b562..29a1262d 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -9,6 +9,7 @@ from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts from app.utils.pricing_utils import format_period_description +from app.utils.subscription_utils import get_display_subscription_link import logging logger = logging.getLogger(__name__) @@ -87,7 +88,7 @@ def get_main_menu_keyboard( if has_active_subscription and subscription_is_active: connect_mode = settings.CONNECT_BUTTON_MODE - subscription_url = getattr(subscription, "subscription_url", None) + subscription_link = get_display_subscription_link(subscription) def _fallback_connect_button() -> InlineKeyboardButton: return InlineKeyboardButton( @@ -96,11 +97,11 @@ def get_main_menu_keyboard( ) if connect_mode == "miniapp_subscription": - if subscription_url: + if subscription_link: keyboard.append([ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription_url) + web_app=types.WebAppInfo(url=subscription_link) ) ]) else: @@ -112,12 +113,12 @@ def get_main_menu_keyboard( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) ) ]) - elif connect_mode == "link": - if subscription_url: + elif connect_mode in {"link", "happ_cryptolink"}: + if subscription_link: keyboard.append([ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription_url + url=subscription_link ) ]) else: @@ -125,6 +126,10 @@ def get_main_menu_keyboard( else: keyboard.append([_fallback_connect_button()]) + happ_row = get_happ_download_button_row(texts) + if happ_row: + keyboard.append(happ_row) + keyboard.append([ InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"), InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription") @@ -227,6 +232,40 @@ def get_main_menu_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) +def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]: + if not settings.is_happ_download_button_enabled(): + return None + + return [ + InlineKeyboardButton( + text=texts.t("HAPP_DOWNLOAD_BUTTON", "⬇️ Скачать Happ"), + callback_data="subscription_happ_download" + ) + ] + + +def get_happ_download_platform_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: + texts = get_texts(language) + buttons = [ + [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), callback_data="happ_download_ios")], + [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), callback_data="happ_download_android")], + [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_PC", "💻 ПК"), callback_data="happ_download_pc")], + [InlineKeyboardButton(text=texts.BACK, callback_data="happ_download_close")], + ] + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_happ_download_link_keyboard(language: str, link: str) -> InlineKeyboardMarkup: + texts = get_texts(language) + buttons = [ + [InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_OPEN_LINK", "🔗 Открыть ссылку"), url=link)], + [InlineKeyboardButton(text=texts.BACK, callback_data="happ_download_back")], + ] + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + def get_back_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ @@ -323,14 +362,15 @@ def get_subscription_keyboard( keyboard = [] if has_subscription: - if subscription and subscription.subscription_url: + subscription_link = get_display_subscription_link(subscription) if subscription else None + if subscription_link: connect_mode = settings.CONNECT_BUTTON_MODE - + if connect_mode == "miniapp_subscription": keyboard.append([ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription.subscription_url) + web_app=types.WebAppInfo(url=subscription_link) ) ]) elif connect_mode == "miniapp_custom": @@ -345,14 +385,29 @@ def get_subscription_keyboard( keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") ]) - elif connect_mode == "link": + elif connect_mode in {"link", "happ_cryptolink"}: keyboard.append([ - InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url) + InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link) ]) else: keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") ]) + elif settings.CONNECT_BUTTON_MODE == "miniapp_custom": + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) + ) + ]) + else: + keyboard.append([ + InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") + ]) + + happ_row = get_happ_download_button_row(texts) + if happ_row: + keyboard.append(happ_row) if not is_trial: keyboard.append([ diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 8fba5d67..94eb4eca 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -19,6 +19,14 @@ "CONFIRM": "✅ Confirm", "CONFIRM_CHANGE_BUTTON": "✅ Confirm change", "CONNECT_BUTTON": "🔗 Connect", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ", + "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_PC": "💻 PC", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", "CONTINUE": "➡️ Continue", "CONTINUE_BUTTON": "➡️ Continue", "COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index d51eee5b..5f0fad3d 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -99,6 +99,14 @@ "CONFIRM": "✅ Подтвердить", "CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение", "CONNECT_BUTTON": "🔗 Подключиться", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ", + "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_PC": "💻 ПК", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", "CONTACT_SUPPORT": "💬 Написать в поддержку", "CONTINUE": "➡️ Продолжить", "CONTINUE_BUTTON": "✅ Продолжить", diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 1f7680f5..966842ef 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -279,6 +279,7 @@ class MonitoringService: ) subscription.subscription_url = updated_user.subscription_url + subscription.subscription_crypto_link = updated_user.happ_crypto_link await db.commit() status_text = "активным" if is_active else "истёкшим" diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index dd6ee50c..94add099 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -458,6 +458,7 @@ class RemnaWaveService: 'usedTrafficBytes': user_obj.used_traffic_bytes, 'hwidDeviceLimit': user_obj.hwid_device_limit, 'subscriptionUrl': user_obj.subscription_url, + 'subscriptionCryptoLink': user_obj.happ_crypto_link, 'activeInternalSquads': user_obj.active_internal_squads } panel_users.append(user_dict) @@ -581,6 +582,7 @@ class RemnaWaveService: subscription.autopay_enabled = False subscription.remnawave_short_uuid = None subscription.subscription_url = "" + subscription.subscription_crypto_link = "" db_user.remnawave_uuid = None @@ -637,14 +639,18 @@ class RemnaWaveService: subscription_data = { 'user_id': user.id, 'status': status.value, - 'is_trial': False, + 'is_trial': False, 'end_date': expire_at, 'traffic_limit_gb': traffic_limit_gb, 'traffic_used_gb': traffic_used_gb, 'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1, 'connected_squads': squad_uuids, 'remnawave_short_uuid': panel_user.get('shortUuid'), - 'subscription_url': panel_user.get('subscriptionUrl', '') + 'subscription_url': panel_user.get('subscriptionUrl', ''), + 'subscription_crypto_link': ( + panel_user.get('subscriptionCryptoLink') + or (panel_user.get('happ') or {}).get('cryptoLink', '') + ) } subscription = await create_subscription(db, **subscription_data) @@ -667,7 +673,11 @@ class RemnaWaveService: device_limit=1, connected_squads=[], remnawave_short_uuid=panel_user.get('shortUuid'), - subscription_url=panel_user.get('subscriptionUrl', '') + subscription_url=panel_user.get('subscriptionUrl', ''), + subscription_crypto_link=( + panel_user.get('subscriptionCryptoLink') + or (panel_user.get('happ') or {}).get('cryptoLink', '') + ) ) logger.info(f"✅ Создана базовая подписка для пользователя {user.telegram_id}") except Exception as basic_error: @@ -733,6 +743,13 @@ class RemnaWaveService: panel_url = panel_user.get('subscriptionUrl', '') if not subscription.subscription_url or subscription.subscription_url != panel_url: subscription.subscription_url = panel_url + + panel_crypto_link = ( + panel_user.get('subscriptionCryptoLink') + or (panel_user.get('happ') or {}).get('cryptoLink', '') + ) + if panel_crypto_link and subscription.subscription_crypto_link != panel_crypto_link: + subscription.subscription_crypto_link = panel_crypto_link active_squads = panel_user.get('activeInternalSquads', []) squad_uuids = [] @@ -1113,6 +1130,7 @@ class RemnaWaveService: user.subscription.autopay_days_before = 3 user.subscription.remnawave_short_uuid = None user.subscription.subscription_url = "" + user.subscription.subscription_crypto_link = "" user.subscription.updated_at = datetime.utcnow() await db.commit() @@ -1295,6 +1313,7 @@ class RemnaWaveService: if rw_user: subscription.remnawave_short_uuid = rw_user.short_uuid subscription.subscription_url = rw_user.subscription_url + subscription.subscription_crypto_link = rw_user.happ_crypto_link logger.info(f"🔧 Восстановлены данные Remnawave для {user.telegram_id}") issues_fixed += 1 except Exception as rw_error: diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 7e25c427..190a9470 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -130,7 +130,8 @@ class SubscriptionService: ) subscription.remnawave_short_uuid = updated_user.short_uuid - subscription.subscription_url = updated_user.subscription_url + subscription.subscription_url = updated_user.subscription_url + subscription.subscription_crypto_link = updated_user.happ_crypto_link user.remnawave_uuid = updated_user.uuid await db.commit() @@ -190,6 +191,7 @@ class SubscriptionService: ) subscription.subscription_url = updated_user.subscription_url + subscription.subscription_crypto_link = updated_user.happ_crypto_link await db.commit() status_text = "активным" if is_actually_active else "истёкшим" @@ -233,6 +235,7 @@ class SubscriptionService: subscription.remnawave_short_uuid = updated_user.short_uuid subscription.subscription_url = updated_user.subscription_url + subscription.subscription_crypto_link = updated_user.happ_crypto_link await db.commit() logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}") @@ -534,6 +537,7 @@ class SubscriptionService: subscription.remnawave_short_uuid = None subscription.subscription_url = "" + subscription.subscription_crypto_link = "" subscription.connected_squads = [] user.remnawave_uuid = None diff --git a/app/utils/subscription_utils.py b/app/utils/subscription_utils.py index f3161202..62db4142 100644 --- a/app/utils/subscription_utils.py +++ b/app/utils/subscription_utils.py @@ -1,9 +1,10 @@ import logging from datetime import datetime from typing import Optional -from sqlalchemy import select, delete +from sqlalchemy import select, delete, func from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import Subscription, User +from app.config import settings logger = logging.getLogger(__name__) @@ -93,5 +94,18 @@ async def cleanup_duplicate_subscriptions(db: AsyncSession) -> int: await db.commit() logger.info(f"🧹 Очищено {total_deleted} дублирующихся подписок") - + return total_deleted + + +def get_display_subscription_link(subscription: Optional[Subscription]) -> Optional[str]: + if not subscription: + return None + + base_link = getattr(subscription, "subscription_url", None) + + if settings.is_happ_cryptolink_mode(): + crypto_link = getattr(subscription, "subscription_crypto_link", None) + return crypto_link or base_link + + return base_link diff --git a/locales/en.json b/locales/en.json index bb1b7fc8..5b3a6b21 100644 --- a/locales/en.json +++ b/locales/en.json @@ -20,6 +20,14 @@ "CONFIRM": "✅ Confirm", "CONFIRM_CHANGE_BUTTON": "✅ Confirm change", "CONNECT_BUTTON": "🔗 Connect", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ", + "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_PC": "💻 PC", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", "CONTINUE": "➡️ Continue", "CONTINUE_BUTTON": "➡️ Continue", "COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link", diff --git a/locales/ru.json b/locales/ru.json index ff9fa404..d1b483af 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -183,6 +183,14 @@ "CONFIRM": "✅ Подтвердить", "CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение", "CONNECT_BUTTON": "🔗 Подключиться", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ", + "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_PC": "💻 ПК", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", "CONTACT_SUPPORT": "💬 Написать в поддержку", "CONTINUE": "➡️ Продолжить", "CONTINUE_BUTTON": "✅ Продолжить", From a8e10858849e4a18f3a98912c3dd8ddec5a329e1 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 10:49:54 +0300 Subject: [PATCH 081/146] Handle Happ cryptolink buttons without unsupported URLs --- app/handlers/subscription.py | 164 ++++++++++++++++++++++++++++++----- app/keyboards/inline.py | 51 +++++++++-- 2 files changed, 184 insertions(+), 31 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 8cc84fd3..f76aba7c 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -566,7 +566,11 @@ async def show_subscription_info( subscription_link = get_display_subscription_link(subscription) if subscription_link: - if actual_status in ['trial_active', 'paid_active'] and not settings.HIDE_SUBSCRIPTION_LINK: + if ( + actual_status in ['trial_active', 'paid_active'] + and not settings.HIDE_SUBSCRIPTION_LINK + and not settings.is_happ_cryptolink_mode() + ): message += "\n\n" + texts.t( "SUBSCRIPTION_CONNECT_LINK_SECTION", "🔗 Ссылка для подключения:\n{subscription_url}", @@ -839,16 +843,30 @@ async def activate_trial( subscription_link = get_display_subscription_link(subscription) if remnawave_user and subscription_link: - subscription_import_link = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription_link) + if settings.is_happ_cryptolink_mode(): + trial_success_text = ( + f"{texts.TRIAL_ACTIVATED}\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_LINK_PROMPT", + "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + ) + + "\n\n" + + texts.t( + 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', + '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве', + ) + ) + else: + subscription_import_link = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", + ).format(subscription_url=subscription_link) - trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" - f"{subscription_import_link}\n\n" - f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" - ) + trial_success_text = ( + f"{texts.TRIAL_ACTIVATED}\n\n" + f"{subscription_import_link}\n\n" + f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" + ) connect_mode = settings.CONNECT_BUTTON_MODE @@ -882,7 +900,7 @@ async def activate_trial( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": rows = [ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] ] @@ -896,6 +914,25 @@ async def activate_trial( ) ]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + elif connect_mode == "happ_cryptolink": + rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu" + ) + ]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -3295,16 +3332,30 @@ async def confirm_purchase( subscription_link = get_display_subscription_link(subscription) if remnawave_user and subscription_link: - import_link_section = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription_link) + if settings.is_happ_cryptolink_mode(): + success_text = ( + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_LINK_PROMPT", + "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + ) + + "\n\n" + + texts.t( + 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', + '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве', + ) + ) + else: + import_link_section = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", + ).format(subscription_url=subscription_link) - success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - f"{import_link_section}\n\n" - f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" - ) + success_text = ( + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + f"{import_link_section}\n\n" + f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" + ) connect_mode = settings.CONNECT_BUTTON_MODE @@ -3338,7 +3389,7 @@ async def confirm_purchase( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": rows = [ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] ] @@ -3347,6 +3398,20 @@ async def confirm_purchase( rows.append(happ_row) rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + elif connect_mode == "happ_cryptolink": + rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -4185,7 +4250,7 @@ async def handle_connect_subscription( parse_mode="HTML" ) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": rows = [ [ InlineKeyboardButton( @@ -4206,14 +4271,41 @@ async def handle_connect_subscription( await callback.message.edit_text( texts.t( "SUBSCRIPTION_CONNECT_LINK_MESSAGE", - """🚀 Подключить подписку + """🚀 Подключить подписку", 🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""", ), reply_markup=keyboard, parse_mode="HTML" ) + elif connect_mode == "happ_cryptolink": + rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + + await callback.message.edit_text( + texts.t( + "SUBSCRIPTION_CONNECT_LINK_MESSAGE", + """🚀 Подключить подписку", + +🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""", + ), + reply_markup=keyboard, + parse_mode="HTML" + ) else: device_text = texts.t( "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE", @@ -4514,6 +4606,32 @@ async def handle_open_subscription_link( ) return + if settings.is_happ_cryptolink_mode(): + happ_message = ( + texts.t( + "SUBSCRIPTION_HAPP_OPEN_TITLE", + "🔗 Подключение через Happ", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_OPEN_LINK", + "🔓 Открыть ссылку в Happ", + ).format(subscription_link=subscription_link) + + "\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_OPEN_HINT", + "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", + ).format(subscription_link=subscription_link) + ) + + await callback.message.answer( + happ_message, + parse_mode="HTML", + disable_web_page_preview=True, + ) + await callback.answer() + return + link_text = ( texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + "\n\n" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 29a1262d..68a5a30b 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -113,7 +113,7 @@ def get_main_menu_keyboard( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) ) ]) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": if subscription_link: keyboard.append([ InlineKeyboardButton( @@ -123,6 +123,16 @@ def get_main_menu_keyboard( ]) else: keyboard.append([_fallback_connect_button()]) + elif connect_mode == "happ_cryptolink": + if subscription_link: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ]) + else: + keyboard.append([_fallback_connect_button()]) else: keyboard.append([_fallback_connect_button()]) @@ -385,10 +395,17 @@ def get_subscription_keyboard( keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") ]) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link) ]) + elif connect_mode == "happ_cryptolink": + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ]) else: keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") @@ -1343,9 +1360,18 @@ def get_connection_guide_keyboard( if app_buttons: keyboard.append(app_buttons) - keyboard.append([ - InlineKeyboardButton(text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), url=subscription_url) - ]) + if settings.is_happ_cryptolink_mode(): + copy_button = InlineKeyboardButton( + text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), + callback_data="open_subscription_link", + ) + else: + copy_button = InlineKeyboardButton( + text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), + url=subscription_url, + ) + + keyboard.append([copy_button]) keyboard.extend([ [ @@ -1416,9 +1442,18 @@ def get_specific_app_keyboard( if app_buttons: keyboard.append(app_buttons) - keyboard.append([ - InlineKeyboardButton(text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), url=subscription_url) - ]) + if settings.is_happ_cryptolink_mode(): + copy_button = InlineKeyboardButton( + text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), + callback_data="open_subscription_link", + ) + else: + copy_button = InlineKeyboardButton( + text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), + url=subscription_url, + ) + + keyboard.append([copy_button]) if 'additionalAfterAddSubscriptionStep' in app and 'buttons' in app['additionalAfterAddSubscriptionStep']: for button in app['additionalAfterAddSubscriptionStep']['buttons']: From ec521b84e787f4aedc6d47bb71685f36163697d4 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 10:58:24 +0300 Subject: [PATCH 082/146] Revert "Handle Happ cryptolink buttons without unsupported URLs" --- app/handlers/subscription.py | 164 +++++------------------------------ app/keyboards/inline.py | 51 ++--------- 2 files changed, 31 insertions(+), 184 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index f76aba7c..8cc84fd3 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -566,11 +566,7 @@ async def show_subscription_info( subscription_link = get_display_subscription_link(subscription) if subscription_link: - if ( - actual_status in ['trial_active', 'paid_active'] - and not settings.HIDE_SUBSCRIPTION_LINK - and not settings.is_happ_cryptolink_mode() - ): + if actual_status in ['trial_active', 'paid_active'] and not settings.HIDE_SUBSCRIPTION_LINK: message += "\n\n" + texts.t( "SUBSCRIPTION_CONNECT_LINK_SECTION", "🔗 Ссылка для подключения:\n{subscription_url}", @@ -843,30 +839,16 @@ async def activate_trial( subscription_link = get_display_subscription_link(subscription) if remnawave_user and subscription_link: - if settings.is_happ_cryptolink_mode(): - trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_LINK_PROMPT", - "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", - ) - + "\n\n" - + texts.t( - 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', - '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве', - ) - ) - else: - subscription_import_link = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", - ).format(subscription_url=subscription_link) + subscription_import_link = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", + ).format(subscription_url=subscription_link) - trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" - f"{subscription_import_link}\n\n" - f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" - ) + trial_success_text = ( + f"{texts.TRIAL_ACTIVATED}\n\n" + f"{subscription_import_link}\n\n" + f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" + ) connect_mode = settings.CONNECT_BUTTON_MODE @@ -900,7 +882,7 @@ async def activate_trial( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode == "link": + elif connect_mode in {"link", "happ_cryptolink"}: rows = [ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] ] @@ -914,25 +896,6 @@ async def activate_trial( ) ]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) - elif connect_mode == "happ_cryptolink": - rows = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", - ) - ] - ] - happ_row = get_happ_download_button_row(texts) - if happ_row: - rows.append(happ_row) - rows.append([ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu" - ) - ]) - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -3332,30 +3295,16 @@ async def confirm_purchase( subscription_link = get_display_subscription_link(subscription) if remnawave_user and subscription_link: - if settings.is_happ_cryptolink_mode(): - success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_LINK_PROMPT", - "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", - ) - + "\n\n" - + texts.t( - 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', - '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве', - ) - ) - else: - import_link_section = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription_link) + import_link_section = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", + ).format(subscription_url=subscription_link) - success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - f"{import_link_section}\n\n" - f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" - ) + success_text = ( + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + f"{import_link_section}\n\n" + f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" + ) connect_mode = settings.CONNECT_BUTTON_MODE @@ -3389,7 +3338,7 @@ async def confirm_purchase( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode == "link": + elif connect_mode in {"link", "happ_cryptolink"}: rows = [ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] ] @@ -3398,20 +3347,6 @@ async def confirm_purchase( rows.append(happ_row) rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) - elif connect_mode == "happ_cryptolink": - rows = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", - ) - ] - ] - happ_row = get_happ_download_button_row(texts) - if happ_row: - rows.append(happ_row) - rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -4250,7 +4185,7 @@ async def handle_connect_subscription( parse_mode="HTML" ) - elif connect_mode == "link": + elif connect_mode in {"link", "happ_cryptolink"}: rows = [ [ InlineKeyboardButton( @@ -4271,41 +4206,14 @@ async def handle_connect_subscription( await callback.message.edit_text( texts.t( "SUBSCRIPTION_CONNECT_LINK_MESSAGE", - """🚀 Подключить подписку", + """🚀 Подключить подписку 🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""", ), reply_markup=keyboard, parse_mode="HTML" ) - elif connect_mode == "happ_cryptolink": - rows = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", - ) - ] - ] - happ_row = get_happ_download_button_row(texts) - if happ_row: - rows.append(happ_row) - rows.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) - keyboard = InlineKeyboardMarkup(inline_keyboard=rows) - - await callback.message.edit_text( - texts.t( - "SUBSCRIPTION_CONNECT_LINK_MESSAGE", - """🚀 Подключить подписку", - -🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""", - ), - reply_markup=keyboard, - parse_mode="HTML" - ) else: device_text = texts.t( "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE", @@ -4606,32 +4514,6 @@ async def handle_open_subscription_link( ) return - if settings.is_happ_cryptolink_mode(): - happ_message = ( - texts.t( - "SUBSCRIPTION_HAPP_OPEN_TITLE", - "🔗 Подключение через Happ", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_OPEN_LINK", - "🔓 Открыть ссылку в Happ", - ).format(subscription_link=subscription_link) - + "\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_OPEN_HINT", - "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", - ).format(subscription_link=subscription_link) - ) - - await callback.message.answer( - happ_message, - parse_mode="HTML", - disable_web_page_preview=True, - ) - await callback.answer() - return - link_text = ( texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + "\n\n" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 68a5a30b..29a1262d 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -113,7 +113,7 @@ def get_main_menu_keyboard( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) ) ]) - elif connect_mode == "link": + elif connect_mode in {"link", "happ_cryptolink"}: if subscription_link: keyboard.append([ InlineKeyboardButton( @@ -123,16 +123,6 @@ def get_main_menu_keyboard( ]) else: keyboard.append([_fallback_connect_button()]) - elif connect_mode == "happ_cryptolink": - if subscription_link: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", - ) - ]) - else: - keyboard.append([_fallback_connect_button()]) else: keyboard.append([_fallback_connect_button()]) @@ -395,17 +385,10 @@ def get_subscription_keyboard( keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") ]) - elif connect_mode == "link": + elif connect_mode in {"link", "happ_cryptolink"}: keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link) ]) - elif connect_mode == "happ_cryptolink": - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", - ) - ]) else: keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") @@ -1360,18 +1343,9 @@ def get_connection_guide_keyboard( if app_buttons: keyboard.append(app_buttons) - if settings.is_happ_cryptolink_mode(): - copy_button = InlineKeyboardButton( - text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), - callback_data="open_subscription_link", - ) - else: - copy_button = InlineKeyboardButton( - text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), - url=subscription_url, - ) - - keyboard.append([copy_button]) + keyboard.append([ + InlineKeyboardButton(text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), url=subscription_url) + ]) keyboard.extend([ [ @@ -1442,18 +1416,9 @@ def get_specific_app_keyboard( if app_buttons: keyboard.append(app_buttons) - if settings.is_happ_cryptolink_mode(): - copy_button = InlineKeyboardButton( - text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), - callback_data="open_subscription_link", - ) - else: - copy_button = InlineKeyboardButton( - text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), - url=subscription_url, - ) - - keyboard.append([copy_button]) + keyboard.append([ + InlineKeyboardButton(text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), url=subscription_url) + ]) if 'additionalAfterAddSubscriptionStep' in app and 'buttons' in app['additionalAfterAddSubscriptionStep']: for button in app['additionalAfterAddSubscriptionStep']['buttons']: From 1dc3013376b7e3886f4611e0dd5873d60a77a553 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 10:59:03 +0300 Subject: [PATCH 083/146] Hide Happ cryptolinks behind connect button --- app/handlers/subscription.py | 276 ++++++++++++++++++++++++++--------- app/keyboards/inline.py | 14 +- 2 files changed, 220 insertions(+), 70 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 8cc84fd3..feec20aa 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -6,6 +6,7 @@ from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession import json import os +import html from typing import Dict, List, Any, Tuple, Optional from app.config import settings, PERIOD_PRICES, get_traffic_prices @@ -567,14 +568,20 @@ async def show_subscription_info( subscription_link = get_display_subscription_link(subscription) if subscription_link: if actual_status in ['trial_active', 'paid_active'] and not settings.HIDE_SUBSCRIPTION_LINK: - message += "\n\n" + texts.t( - "SUBSCRIPTION_CONNECT_LINK_SECTION", - "🔗 Ссылка для подключения:\n{subscription_url}", - ).format(subscription_url=subscription_link) - message += "\n\n" + texts.t( - "SUBSCRIPTION_CONNECT_LINK_PROMPT", - "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", - ) + if settings.is_happ_cryptolink_mode(): + message += "\n\n" + texts.t( + "SUBSCRIPTION_CONNECT_LINK_SECTION_HAPP", + "🔒 Ссылка для подключения скрыта. Нажмите кнопку \"Подключиться\", чтобы открыть её в Happ.", + ) + else: + message += "\n\n" + texts.t( + "SUBSCRIPTION_CONNECT_LINK_SECTION", + "🔗 Ссылка для подключения:\n{subscription_url}", + ).format(subscription_url=subscription_link) + message += "\n\n" + texts.t( + "SUBSCRIPTION_CONNECT_LINK_PROMPT", + "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", + ) await callback.message.edit_text( message, @@ -839,17 +846,29 @@ async def activate_trial( subscription_link = get_display_subscription_link(subscription) if remnawave_user and subscription_link: - subscription_import_link = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription_link) + if settings.is_happ_cryptolink_mode(): + subscription_import_link = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION_HAPP", + "🔒 Ссылка на подписку скрыта. Нажмите кнопку \"Подключиться\", чтобы открыть её в Happ.", + ) + else: + subscription_import_link = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", + ).format(subscription_url=subscription_link) - trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" - f"{subscription_import_link}\n\n" - f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" + message_parts = [texts.TRIAL_ACTIVATED] + if subscription_import_link: + message_parts.append(subscription_import_link) + message_parts.append( + texts.t( + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", + "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + ) ) + trial_success_text = "\n\n".join(message_parts) + connect_mode = settings.CONNECT_BUTTON_MODE if connect_mode == "miniapp_subscription": @@ -882,9 +901,33 @@ async def activate_trial( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": rows = [ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=subscription_link, + ) + ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu" + ) + ]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + elif connect_mode == "happ_cryptolink": + rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ] ] happ_row = get_happ_download_button_row(texts) if happ_row: @@ -3295,17 +3338,29 @@ async def confirm_purchase( subscription_link = get_display_subscription_link(subscription) if remnawave_user and subscription_link: - import_link_section = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription_link) + if settings.is_happ_cryptolink_mode(): + import_link_section = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION_HAPP", + "🔒 Ссылка на подписку скрыта. Нажмите кнопку \"Подключиться\", чтобы открыть её в Happ.", + ) + else: + import_link_section = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", + ).format(subscription_url=subscription_link) - success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - f"{import_link_section}\n\n" - f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" + message_parts = [texts.SUBSCRIPTION_PURCHASED] + if import_link_section: + message_parts.append(import_link_section) + message_parts.append( + texts.t( + 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', + '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве' + ) ) + success_text = "\n\n".join(message_parts) + connect_mode = settings.CONNECT_BUTTON_MODE if connect_mode == "miniapp_subscription": @@ -3338,14 +3393,43 @@ async def confirm_purchase( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": rows = [ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=subscription_link, + ) + ] ] happ_row = get_happ_download_button_row(texts) if happ_row: rows.append(happ_row) - rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) + rows.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu", + ) + ]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + elif connect_mode == "happ_cryptolink": + rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu", + ) + ]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ @@ -4185,7 +4269,7 @@ async def handle_connect_subscription( parse_mode="HTML" ) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": rows = [ [ InlineKeyboardButton( @@ -4213,6 +4297,34 @@ async def handle_connect_subscription( reply_markup=keyboard, parse_mode="HTML" ) + elif connect_mode == "happ_cryptolink": + rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link" + ) + ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + + await callback.message.edit_text( + texts.t( + "SUBSCRIPTION_CONNECT_HAPP_MESSAGE", + """🚀 Подключить подписку + +🔒 Ссылка скрыта. Нажмите «Подключиться», чтобы открыть её в Happ.""", + ), + reply_markup=keyboard, + parse_mode="HTML" + ) else: device_text = texts.t( @@ -4514,49 +4626,77 @@ async def handle_open_subscription_link( ) return - link_text = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + "\n\n" - + f"{subscription_link}\n\n" - + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:") - + "\n" - + "\n".join( - [ - texts.t( - "SUBSCRIPTION_LINK_STEP1", - "1. Нажмите на ссылку выше чтобы её скопировать", - ), - texts.t( - "SUBSCRIPTION_LINK_STEP2", - "2. Откройте ваше VPN приложение", - ), - texts.t( - "SUBSCRIPTION_LINK_STEP3", - "3. Найдите функцию \"Добавить подписку\" или \"Import\"", - ), - texts.t( - "SUBSCRIPTION_LINK_STEP4", - "4. Вставьте скопированную ссылку", - ), - ] + escaped_link = html.escape(subscription_link) + + if settings.is_happ_cryptolink_mode(): + link_text = texts.t( + "SUBSCRIPTION_HAPP_LINK_DETAILS", + """🔗 Открыть подписку в Happ: +Нажмите здесь, чтобы открыть Happ + +Если ссылка не открывается автоматически, скопируйте её вручную: +{subscription_url_code}""", + ).format( + subscription_url=html.escape(subscription_link, quote=True), + subscription_url_code=escaped_link, ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_LINK_HINT", - "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", + else: + link_text = ( + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + + "\n\n" + + f"{escaped_link}\n\n" + + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:") + + "\n" + + "\n".join( + [ + texts.t( + "SUBSCRIPTION_LINK_STEP1", + "1. Нажмите на ссылку выше чтобы её скопировать", + ), + texts.t( + "SUBSCRIPTION_LINK_STEP2", + "2. Откройте ваше VPN приложение", + ), + texts.t( + "SUBSCRIPTION_LINK_STEP3", + "3. Найдите функцию \"Добавить подписку\" или \"Import\"", + ), + texts.t( + "SUBSCRIPTION_LINK_STEP4", + "4. Вставьте скопированную ссылку", + ), + ] + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_LINK_HINT", + "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", + ) ) - ) + + buttons = [] + if settings.is_happ_cryptolink_mode(): + buttons.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ]) + else: + buttons.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect", + ) + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) await callback.message.edit_text( link_text, - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ] - ]), + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), parse_mode="HTML" ) await callback.answer() diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 29a1262d..115fdeb8 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -385,9 +385,19 @@ def get_subscription_keyboard( keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") ]) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": keyboard.append([ - InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link) + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=subscription_link + ) + ]) + elif connect_mode == "happ_cryptolink": + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link" + ) ]) else: keyboard.append([ From 56829717220ba6811084974f535dc5361c93d48e Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:00:27 +0300 Subject: [PATCH 084/146] Revert "Hide Happ cryptolinks behind connect button" --- app/handlers/subscription.py | 276 +++++++++-------------------------- app/keyboards/inline.py | 14 +- 2 files changed, 70 insertions(+), 220 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index feec20aa..8cc84fd3 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -6,7 +6,6 @@ from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession import json import os -import html from typing import Dict, List, Any, Tuple, Optional from app.config import settings, PERIOD_PRICES, get_traffic_prices @@ -568,20 +567,14 @@ async def show_subscription_info( subscription_link = get_display_subscription_link(subscription) if subscription_link: if actual_status in ['trial_active', 'paid_active'] and not settings.HIDE_SUBSCRIPTION_LINK: - if settings.is_happ_cryptolink_mode(): - message += "\n\n" + texts.t( - "SUBSCRIPTION_CONNECT_LINK_SECTION_HAPP", - "🔒 Ссылка для подключения скрыта. Нажмите кнопку \"Подключиться\", чтобы открыть её в Happ.", - ) - else: - message += "\n\n" + texts.t( - "SUBSCRIPTION_CONNECT_LINK_SECTION", - "🔗 Ссылка для подключения:\n{subscription_url}", - ).format(subscription_url=subscription_link) - message += "\n\n" + texts.t( - "SUBSCRIPTION_CONNECT_LINK_PROMPT", - "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", - ) + message += "\n\n" + texts.t( + "SUBSCRIPTION_CONNECT_LINK_SECTION", + "🔗 Ссылка для подключения:\n{subscription_url}", + ).format(subscription_url=subscription_link) + message += "\n\n" + texts.t( + "SUBSCRIPTION_CONNECT_LINK_PROMPT", + "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", + ) await callback.message.edit_text( message, @@ -846,29 +839,17 @@ async def activate_trial( subscription_link = get_display_subscription_link(subscription) if remnawave_user and subscription_link: - if settings.is_happ_cryptolink_mode(): - subscription_import_link = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION_HAPP", - "🔒 Ссылка на подписку скрыта. Нажмите кнопку \"Подключиться\", чтобы открыть её в Happ.", - ) - else: - subscription_import_link = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription_link) + subscription_import_link = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", + ).format(subscription_url=subscription_link) - message_parts = [texts.TRIAL_ACTIVATED] - if subscription_import_link: - message_parts.append(subscription_import_link) - message_parts.append( - texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - ) + trial_success_text = ( + f"{texts.TRIAL_ACTIVATED}\n\n" + f"{subscription_import_link}\n\n" + f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" ) - trial_success_text = "\n\n".join(message_parts) - connect_mode = settings.CONNECT_BUTTON_MODE if connect_mode == "miniapp_subscription": @@ -901,33 +882,9 @@ async def activate_trial( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode == "link": + elif connect_mode in {"link", "happ_cryptolink"}: rows = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription_link, - ) - ] - ] - happ_row = get_happ_download_button_row(texts) - if happ_row: - rows.append(happ_row) - rows.append([ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu" - ) - ]) - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) - elif connect_mode == "happ_cryptolink": - rows = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", - ) - ] + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] ] happ_row = get_happ_download_button_row(texts) if happ_row: @@ -3338,29 +3295,17 @@ async def confirm_purchase( subscription_link = get_display_subscription_link(subscription) if remnawave_user and subscription_link: - if settings.is_happ_cryptolink_mode(): - import_link_section = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION_HAPP", - "🔒 Ссылка на подписку скрыта. Нажмите кнопку \"Подключиться\", чтобы открыть её в Happ.", - ) - else: - import_link_section = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription_link) + import_link_section = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", + ).format(subscription_url=subscription_link) - message_parts = [texts.SUBSCRIPTION_PURCHASED] - if import_link_section: - message_parts.append(import_link_section) - message_parts.append( - texts.t( - 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', - '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве' - ) + success_text = ( + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + f"{import_link_section}\n\n" + f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" ) - success_text = "\n\n".join(message_parts) - connect_mode = settings.CONNECT_BUTTON_MODE if connect_mode == "miniapp_subscription": @@ -3393,43 +3338,14 @@ async def confirm_purchase( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode == "link": + elif connect_mode in {"link", "happ_cryptolink"}: rows = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription_link, - ) - ] + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] ] happ_row = get_happ_download_button_row(texts) if happ_row: rows.append(happ_row) - rows.append([ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ]) - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) - elif connect_mode == "happ_cryptolink": - rows = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", - ) - ] - ] - happ_row = get_happ_download_button_row(texts) - if happ_row: - rows.append(happ_row) - rows.append([ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ]) + rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ @@ -4269,7 +4185,7 @@ async def handle_connect_subscription( parse_mode="HTML" ) - elif connect_mode == "link": + elif connect_mode in {"link", "happ_cryptolink"}: rows = [ [ InlineKeyboardButton( @@ -4297,34 +4213,6 @@ async def handle_connect_subscription( reply_markup=keyboard, parse_mode="HTML" ) - elif connect_mode == "happ_cryptolink": - rows = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link" - ) - ] - ] - happ_row = get_happ_download_button_row(texts) - if happ_row: - rows.append(happ_row) - rows.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) - - keyboard = InlineKeyboardMarkup(inline_keyboard=rows) - - await callback.message.edit_text( - texts.t( - "SUBSCRIPTION_CONNECT_HAPP_MESSAGE", - """🚀 Подключить подписку - -🔒 Ссылка скрыта. Нажмите «Подключиться», чтобы открыть её в Happ.""", - ), - reply_markup=keyboard, - parse_mode="HTML" - ) else: device_text = texts.t( @@ -4626,77 +4514,49 @@ async def handle_open_subscription_link( ) return - escaped_link = html.escape(subscription_link) - - if settings.is_happ_cryptolink_mode(): - link_text = texts.t( - "SUBSCRIPTION_HAPP_LINK_DETAILS", - """🔗 Открыть подписку в Happ: -Нажмите здесь, чтобы открыть Happ - -Если ссылка не открывается автоматически, скопируйте её вручную: -{subscription_url_code}""", - ).format( - subscription_url=html.escape(subscription_link, quote=True), - subscription_url_code=escaped_link, + link_text = ( + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + + "\n\n" + + f"{subscription_link}\n\n" + + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:") + + "\n" + + "\n".join( + [ + texts.t( + "SUBSCRIPTION_LINK_STEP1", + "1. Нажмите на ссылку выше чтобы её скопировать", + ), + texts.t( + "SUBSCRIPTION_LINK_STEP2", + "2. Откройте ваше VPN приложение", + ), + texts.t( + "SUBSCRIPTION_LINK_STEP3", + "3. Найдите функцию \"Добавить подписку\" или \"Import\"", + ), + texts.t( + "SUBSCRIPTION_LINK_STEP4", + "4. Вставьте скопированную ссылку", + ), + ] ) - else: - link_text = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + "\n\n" - + f"{escaped_link}\n\n" - + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:") - + "\n" - + "\n".join( - [ - texts.t( - "SUBSCRIPTION_LINK_STEP1", - "1. Нажмите на ссылку выше чтобы её скопировать", - ), - texts.t( - "SUBSCRIPTION_LINK_STEP2", - "2. Откройте ваше VPN приложение", - ), - texts.t( - "SUBSCRIPTION_LINK_STEP3", - "3. Найдите функцию \"Добавить подписку\" или \"Import\"", - ), - texts.t( - "SUBSCRIPTION_LINK_STEP4", - "4. Вставьте скопированную ссылку", - ), - ] - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_LINK_HINT", - "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", - ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_LINK_HINT", + "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", ) - - buttons = [] - if settings.is_happ_cryptolink_mode(): - buttons.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", - ) - ]) - else: - buttons.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect", - ) - ]) - - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) + ) await callback.message.edit_text( link_text, - reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ] + ]), parse_mode="HTML" ) await callback.answer() diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 115fdeb8..29a1262d 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -385,19 +385,9 @@ def get_subscription_keyboard( keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") ]) - elif connect_mode == "link": + elif connect_mode in {"link", "happ_cryptolink"}: keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription_link - ) - ]) - elif connect_mode == "happ_cryptolink": - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link" - ) + InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link) ]) else: keyboard.append([ From 65374e38b3b024f100f4ae69506b14977f3d3b21 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:00:52 +0300 Subject: [PATCH 085/146] Handle Happ crypto links in subscription flows --- app/handlers/subscription.py | 167 ++++++++++++++++++++++++++++++----- app/keyboards/inline.py | 21 ++++- locales/en.json | 3 + locales/ru.json | 3 + 4 files changed, 169 insertions(+), 25 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 8cc84fd3..69dffd42 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -567,14 +567,20 @@ async def show_subscription_info( subscription_link = get_display_subscription_link(subscription) if subscription_link: if actual_status in ['trial_active', 'paid_active'] and not settings.HIDE_SUBSCRIPTION_LINK: - message += "\n\n" + texts.t( - "SUBSCRIPTION_CONNECT_LINK_SECTION", - "🔗 Ссылка для подключения:\n{subscription_url}", - ).format(subscription_url=subscription_link) - message += "\n\n" + texts.t( - "SUBSCRIPTION_CONNECT_LINK_PROMPT", - "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", - ) + if settings.is_happ_cryptolink_mode(): + message += "\n\n" + texts.t( + 'HAPP_CRYPTO_LINK_BUTTON_PROMPT', + '🔐 Подключение через Happ\nНажмите кнопку «Подключиться» ниже, чтобы получить защищённую ссылку.' + ) + else: + message += "\n\n" + texts.t( + 'SUBSCRIPTION_CONNECT_LINK_SECTION', + '🔗 Ссылка для подключения:\n{subscription_url}' + ).format(subscription_url=subscription_link) + message += "\n\n" + texts.t( + 'SUBSCRIPTION_CONNECT_LINK_PROMPT', + '📱 Скопируйте ссылку и добавьте в ваше VPN приложение' + ) await callback.message.edit_text( message, @@ -839,10 +845,16 @@ async def activate_trial( subscription_link = get_display_subscription_link(subscription) if remnawave_user and subscription_link: - subscription_import_link = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription_link) + if settings.is_happ_cryptolink_mode(): + subscription_import_link = texts.t( + "HAPP_CRYPTO_LINK_BUTTON_PROMPT", + "🔐 Подключение через Happ\\nНажмите кнопку «Подключиться» ниже, чтобы получить защищённую ссылку.", + ) + else: + subscription_import_link = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", + ).format(subscription_url=subscription_link) trial_success_text = ( f"{texts.TRIAL_ACTIVATED}\n\n" @@ -882,7 +894,7 @@ async def activate_trial( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": rows = [ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] ] @@ -896,6 +908,25 @@ async def activate_trial( ) ]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + elif connect_mode == "happ_cryptolink": + rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_happ_subscription_link" + ) + ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu" + ) + ]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -2693,8 +2724,18 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess info_text += f"\n💰 Стоимость подписки в месяц: {texts.format_price(subscription_cost)}" if subscription_url and subscription_url != "Генерируется...": - info_text += f"\n\n🔗 Ваша ссылка для импорта в VPN приложениe:\n{subscription_url}" - + if settings.is_happ_cryptolink_mode(): + info_text += ( + "\n\n" + + texts.t( + 'HAPP_CRYPTO_LINK_BUTTON_PROMPT', + '🔐 Подключение через Happ\nНажмите кнопку «Подключиться» ниже, чтобы получить защищённую ссылку.' + ) + ) + else: + info_text += ( + f"\n\n🔗 Ваша ссылка для импорта в VPN приложениe:\n{subscription_url}" + ) return info_text def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str: @@ -3295,14 +3336,24 @@ async def confirm_purchase( subscription_link = get_display_subscription_link(subscription) if remnawave_user and subscription_link: - import_link_section = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription_link) + if settings.is_happ_cryptolink_mode(): + import_link_section = texts.t( + 'HAPP_CRYPTO_LINK_BUTTON_PROMPT', + '🔐 Подключение через Happ\nНажмите кнопку «Подключиться» ниже, чтобы получить защищённую ссылку.' + ) + else: + import_link_section = texts.t( + 'SUBSCRIPTION_IMPORT_LINK_SECTION', + '🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}' + ).format(subscription_url=subscription_link) success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - f"{import_link_section}\n\n" + f"{texts.SUBSCRIPTION_PURCHASED} + +" + f"{import_link_section} + +" f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" ) @@ -3338,7 +3389,7 @@ async def confirm_purchase( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": rows = [ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] ] @@ -3347,6 +3398,15 @@ async def confirm_purchase( rows.append(happ_row) rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + elif connect_mode == "happ_cryptolink": + rows = [ + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="open_happ_subscription_link")] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -4185,7 +4245,7 @@ async def handle_connect_subscription( parse_mode="HTML" ) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": rows = [ [ InlineKeyboardButton( @@ -4213,6 +4273,32 @@ async def handle_connect_subscription( reply_markup=keyboard, parse_mode="HTML" ) + elif connect_mode == "happ_cryptolink": + rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_happ_subscription_link" + ) + ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + + await callback.message.edit_text( + texts.t( + "HAPP_CRYPTO_LINK_BUTTON_PROMPT", + "🔐 Подключение через Happ\nНажмите кнопку «Подключиться» ниже, чтобы получить защищённую ссылку.", + ), + reply_markup=keyboard, + parse_mode="HTML" + ) else: device_text = texts.t( @@ -4498,6 +4584,36 @@ async def handle_no_traffic_packages( ) +async def handle_open_happ_subscription_link( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + texts = get_texts(db_user.language) + subscription = db_user.subscription + subscription_link = get_display_subscription_link(subscription) + + if not subscription_link: + await callback.answer( + texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"), + show_alert=True, + ) + return + + message_text = texts.t( + "HAPP_CRYPTO_LINK_MESSAGE", + "🔐 Ссылка для Happ\n\nНажмите на ссылку ниже, чтобы открыть Happ, или скопируйте её вручную:\n\n{subscription_link}\n\nЕсли приложение не открылось автоматически, откройте Happ и вставьте ссылку вручную.", + ).format(subscription_link=subscription_link) + + await callback.answer(texts.t("HAPP_CRYPTO_LINK_SENT", "🔐 Ссылка отправлена ниже.")) + + await callback.message.answer( + message_text, + parse_mode="HTML", + disable_web_page_preview=True, + ) + + async def handle_open_subscription_link( callback: types.CallbackQuery, db_user: User, @@ -5260,6 +5376,11 @@ def register_handlers(dp: Dispatcher): F.data == "open_subscription_link" ) + dp.callback_query.register( + handle_open_happ_subscription_link, + F.data == "open_happ_subscription_link" + ) + dp.callback_query.register( handle_subscription_settings, F.data == "subscription_settings" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 29a1262d..36b0d85c 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -113,7 +113,7 @@ def get_main_menu_keyboard( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) ) ]) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": if subscription_link: keyboard.append([ InlineKeyboardButton( @@ -123,6 +123,16 @@ def get_main_menu_keyboard( ]) else: keyboard.append([_fallback_connect_button()]) + elif connect_mode == "happ_cryptolink": + if subscription_link: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_happ_subscription_link" + ) + ]) + else: + keyboard.append([_fallback_connect_button()]) else: keyboard.append([_fallback_connect_button()]) @@ -385,10 +395,17 @@ def get_subscription_keyboard( keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") ]) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link) ]) + elif connect_mode == "happ_cryptolink": + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_happ_subscription_link" + ) + ]) else: keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") diff --git a/locales/en.json b/locales/en.json index 5b3a6b21..ed11ad2c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -28,6 +28,9 @@ "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", + "HAPP_CRYPTO_LINK_BUTTON_PROMPT": "🔐 Connect via Happ\nTap the “Connect” button below to reveal your secure link.", + "HAPP_CRYPTO_LINK_MESSAGE": "🔐 Happ link\n\nTap the link below to open Happ or copy it manually:\n\n{subscription_link}\n\nIf the app doesn't open automatically, launch Happ and paste the link manually.", + "HAPP_CRYPTO_LINK_SENT": "🔐 The link has been sent below.", "CONTINUE": "➡️ Continue", "CONTINUE_BUTTON": "➡️ Continue", "COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link", diff --git a/locales/ru.json b/locales/ru.json index d1b483af..0351e1a9 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -191,6 +191,9 @@ "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", + "HAPP_CRYPTO_LINK_BUTTON_PROMPT": "🔐 Подключение через Happ\nНажмите кнопку «Подключиться» ниже, чтобы получить защищённую ссылку.", + "HAPP_CRYPTO_LINK_MESSAGE": "🔐 Ссылка для Happ\n\nНажмите на ссылку ниже, чтобы открыть Happ, или скопируйте её вручную:\n\n{subscription_link}\n\nЕсли приложение не открылось автоматически, откройте Happ и вставьте ссылку вручную.", + "HAPP_CRYPTO_LINK_SENT": "🔐 Ссылка отправлена ниже.", "CONTACT_SUPPORT": "💬 Написать в поддержку", "CONTINUE": "➡️ Продолжить", "CONTINUE_BUTTON": "✅ Продолжить", From cb41f9263f3e4a488788d6ee7fab181c5031eae1 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:01:41 +0300 Subject: [PATCH 086/146] Revert "Handle Happ crypto links in subscription flows" --- app/handlers/subscription.py | 167 +++++------------------------------ app/keyboards/inline.py | 21 +---- locales/en.json | 3 - locales/ru.json | 3 - 4 files changed, 25 insertions(+), 169 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 69dffd42..8cc84fd3 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -567,20 +567,14 @@ async def show_subscription_info( subscription_link = get_display_subscription_link(subscription) if subscription_link: if actual_status in ['trial_active', 'paid_active'] and not settings.HIDE_SUBSCRIPTION_LINK: - if settings.is_happ_cryptolink_mode(): - message += "\n\n" + texts.t( - 'HAPP_CRYPTO_LINK_BUTTON_PROMPT', - '🔐 Подключение через Happ\nНажмите кнопку «Подключиться» ниже, чтобы получить защищённую ссылку.' - ) - else: - message += "\n\n" + texts.t( - 'SUBSCRIPTION_CONNECT_LINK_SECTION', - '🔗 Ссылка для подключения:\n{subscription_url}' - ).format(subscription_url=subscription_link) - message += "\n\n" + texts.t( - 'SUBSCRIPTION_CONNECT_LINK_PROMPT', - '📱 Скопируйте ссылку и добавьте в ваше VPN приложение' - ) + message += "\n\n" + texts.t( + "SUBSCRIPTION_CONNECT_LINK_SECTION", + "🔗 Ссылка для подключения:\n{subscription_url}", + ).format(subscription_url=subscription_link) + message += "\n\n" + texts.t( + "SUBSCRIPTION_CONNECT_LINK_PROMPT", + "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", + ) await callback.message.edit_text( message, @@ -845,16 +839,10 @@ async def activate_trial( subscription_link = get_display_subscription_link(subscription) if remnawave_user and subscription_link: - if settings.is_happ_cryptolink_mode(): - subscription_import_link = texts.t( - "HAPP_CRYPTO_LINK_BUTTON_PROMPT", - "🔐 Подключение через Happ\\nНажмите кнопку «Подключиться» ниже, чтобы получить защищённую ссылку.", - ) - else: - subscription_import_link = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription_link) + subscription_import_link = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", + ).format(subscription_url=subscription_link) trial_success_text = ( f"{texts.TRIAL_ACTIVATED}\n\n" @@ -894,7 +882,7 @@ async def activate_trial( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode == "link": + elif connect_mode in {"link", "happ_cryptolink"}: rows = [ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] ] @@ -908,25 +896,6 @@ async def activate_trial( ) ]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) - elif connect_mode == "happ_cryptolink": - rows = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_happ_subscription_link" - ) - ] - ] - happ_row = get_happ_download_button_row(texts) - if happ_row: - rows.append(happ_row) - rows.append([ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu" - ) - ]) - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -2724,18 +2693,8 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess info_text += f"\n💰 Стоимость подписки в месяц: {texts.format_price(subscription_cost)}" if subscription_url and subscription_url != "Генерируется...": - if settings.is_happ_cryptolink_mode(): - info_text += ( - "\n\n" - + texts.t( - 'HAPP_CRYPTO_LINK_BUTTON_PROMPT', - '🔐 Подключение через Happ\nНажмите кнопку «Подключиться» ниже, чтобы получить защищённую ссылку.' - ) - ) - else: - info_text += ( - f"\n\n🔗 Ваша ссылка для импорта в VPN приложениe:\n{subscription_url}" - ) + info_text += f"\n\n🔗 Ваша ссылка для импорта в VPN приложениe:\n{subscription_url}" + return info_text def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str: @@ -3336,24 +3295,14 @@ async def confirm_purchase( subscription_link = get_display_subscription_link(subscription) if remnawave_user and subscription_link: - if settings.is_happ_cryptolink_mode(): - import_link_section = texts.t( - 'HAPP_CRYPTO_LINK_BUTTON_PROMPT', - '🔐 Подключение через Happ\nНажмите кнопку «Подключиться» ниже, чтобы получить защищённую ссылку.' - ) - else: - import_link_section = texts.t( - 'SUBSCRIPTION_IMPORT_LINK_SECTION', - '🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}' - ).format(subscription_url=subscription_link) + import_link_section = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", + ).format(subscription_url=subscription_link) success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED} - -" - f"{import_link_section} - -" + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + f"{import_link_section}\n\n" f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" ) @@ -3389,7 +3338,7 @@ async def confirm_purchase( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode == "link": + elif connect_mode in {"link", "happ_cryptolink"}: rows = [ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] ] @@ -3398,15 +3347,6 @@ async def confirm_purchase( rows.append(happ_row) rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) - elif connect_mode == "happ_cryptolink": - rows = [ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="open_happ_subscription_link")] - ] - happ_row = get_happ_download_button_row(texts) - if happ_row: - rows.append(happ_row) - rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -4245,7 +4185,7 @@ async def handle_connect_subscription( parse_mode="HTML" ) - elif connect_mode == "link": + elif connect_mode in {"link", "happ_cryptolink"}: rows = [ [ InlineKeyboardButton( @@ -4273,32 +4213,6 @@ async def handle_connect_subscription( reply_markup=keyboard, parse_mode="HTML" ) - elif connect_mode == "happ_cryptolink": - rows = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_happ_subscription_link" - ) - ] - ] - happ_row = get_happ_download_button_row(texts) - if happ_row: - rows.append(happ_row) - rows.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) - - keyboard = InlineKeyboardMarkup(inline_keyboard=rows) - - await callback.message.edit_text( - texts.t( - "HAPP_CRYPTO_LINK_BUTTON_PROMPT", - "🔐 Подключение через Happ\nНажмите кнопку «Подключиться» ниже, чтобы получить защищённую ссылку.", - ), - reply_markup=keyboard, - parse_mode="HTML" - ) else: device_text = texts.t( @@ -4584,36 +4498,6 @@ async def handle_no_traffic_packages( ) -async def handle_open_happ_subscription_link( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - texts = get_texts(db_user.language) - subscription = db_user.subscription - subscription_link = get_display_subscription_link(subscription) - - if not subscription_link: - await callback.answer( - texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"), - show_alert=True, - ) - return - - message_text = texts.t( - "HAPP_CRYPTO_LINK_MESSAGE", - "🔐 Ссылка для Happ\n\nНажмите на ссылку ниже, чтобы открыть Happ, или скопируйте её вручную:\n\n{subscription_link}\n\nЕсли приложение не открылось автоматически, откройте Happ и вставьте ссылку вручную.", - ).format(subscription_link=subscription_link) - - await callback.answer(texts.t("HAPP_CRYPTO_LINK_SENT", "🔐 Ссылка отправлена ниже.")) - - await callback.message.answer( - message_text, - parse_mode="HTML", - disable_web_page_preview=True, - ) - - async def handle_open_subscription_link( callback: types.CallbackQuery, db_user: User, @@ -5376,11 +5260,6 @@ def register_handlers(dp: Dispatcher): F.data == "open_subscription_link" ) - dp.callback_query.register( - handle_open_happ_subscription_link, - F.data == "open_happ_subscription_link" - ) - dp.callback_query.register( handle_subscription_settings, F.data == "subscription_settings" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 36b0d85c..29a1262d 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -113,7 +113,7 @@ def get_main_menu_keyboard( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) ) ]) - elif connect_mode == "link": + elif connect_mode in {"link", "happ_cryptolink"}: if subscription_link: keyboard.append([ InlineKeyboardButton( @@ -123,16 +123,6 @@ def get_main_menu_keyboard( ]) else: keyboard.append([_fallback_connect_button()]) - elif connect_mode == "happ_cryptolink": - if subscription_link: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_happ_subscription_link" - ) - ]) - else: - keyboard.append([_fallback_connect_button()]) else: keyboard.append([_fallback_connect_button()]) @@ -395,17 +385,10 @@ def get_subscription_keyboard( keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") ]) - elif connect_mode == "link": + elif connect_mode in {"link", "happ_cryptolink"}: keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link) ]) - elif connect_mode == "happ_cryptolink": - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_happ_subscription_link" - ) - ]) else: keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") diff --git a/locales/en.json b/locales/en.json index ed11ad2c..5b3a6b21 100644 --- a/locales/en.json +++ b/locales/en.json @@ -28,9 +28,6 @@ "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", - "HAPP_CRYPTO_LINK_BUTTON_PROMPT": "🔐 Connect via Happ\nTap the “Connect” button below to reveal your secure link.", - "HAPP_CRYPTO_LINK_MESSAGE": "🔐 Happ link\n\nTap the link below to open Happ or copy it manually:\n\n{subscription_link}\n\nIf the app doesn't open automatically, launch Happ and paste the link manually.", - "HAPP_CRYPTO_LINK_SENT": "🔐 The link has been sent below.", "CONTINUE": "➡️ Continue", "CONTINUE_BUTTON": "➡️ Continue", "COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link", diff --git a/locales/ru.json b/locales/ru.json index 0351e1a9..d1b483af 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -191,9 +191,6 @@ "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", - "HAPP_CRYPTO_LINK_BUTTON_PROMPT": "🔐 Подключение через Happ\nНажмите кнопку «Подключиться» ниже, чтобы получить защищённую ссылку.", - "HAPP_CRYPTO_LINK_MESSAGE": "🔐 Ссылка для Happ\n\nНажмите на ссылку ниже, чтобы открыть Happ, или скопируйте её вручную:\n\n{subscription_link}\n\nЕсли приложение не открылось автоматически, откройте Happ и вставьте ссылку вручную.", - "HAPP_CRYPTO_LINK_SENT": "🔐 Ссылка отправлена ниже.", "CONTACT_SUPPORT": "💬 Написать в поддержку", "CONTINUE": "➡️ Продолжить", "CONTINUE_BUTTON": "✅ Продолжить", From de8853bd7c08cb436184da5d04c9226ecdee8e2f Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:02:27 +0300 Subject: [PATCH 087/146] Add Happ cryptoLink proxy support --- .env.example | 2 + app/config.py | 19 +++++ app/external/webhook_server.py | 32 +++++++- app/utils/happ_links.py | 133 ++++++++++++++++++++++++++++++++ app/utils/subscription_utils.py | 8 +- main.py | 5 +- 6 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 app/utils/happ_links.py diff --git a/.env.example b/.env.example index 1070f38a..e405f914 100644 --- a/.env.example +++ b/.env.example @@ -291,6 +291,8 @@ CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_PC= +HAPP_CRYPTOLINK_PROXY_BASE_URL= +HAPP_CRYPTOLINK_PROXY_PATH=/happ-link # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false diff --git a/app/config.py b/app/config.py index bace5055..2d61952f 100644 --- a/app/config.py +++ b/app/config.py @@ -210,6 +210,8 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False + HAPP_CRYPTOLINK_PROXY_BASE_URL: Optional[str] = None + HAPP_CRYPTOLINK_PROXY_PATH: str = "/happ-link" HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None HAPP_DOWNLOAD_LINK_PC: Optional[str] = None @@ -547,9 +549,26 @@ class Settings(BaseSettings): def get_cryptobot_invoice_expires_seconds(self) -> int: return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600 + def get_happ_cryptolink_proxy_base_url(self) -> Optional[str]: + base_url = (self.HAPP_CRYPTOLINK_PROXY_BASE_URL or self.WEBHOOK_URL or "").strip() + if not base_url: + return None + return base_url.rstrip('/') + + def get_happ_cryptolink_proxy_path(self) -> str: + path = (self.HAPP_CRYPTOLINK_PROXY_PATH or "").strip() + if not path: + path = "/happ-link" + if not path.startswith('/'): + path = f"/{path}" + return path + def is_happ_cryptolink_mode(self) -> bool: return self.CONNECT_BUTTON_MODE == "happ_cryptolink" + def is_happ_cryptolink_proxy_enabled(self) -> bool: + return self.is_happ_cryptolink_mode() and self.get_happ_cryptolink_proxy_base_url() is not None + def is_happ_download_button_enabled(self) -> bool: return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index 9ea90945..7cef933d 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -8,6 +8,11 @@ from aiohttp import web from aiogram import Bot from app.config import settings +from app.utils.happ_links import ( + HAPP_LINK_QUERY_PARAM, + decode_happ_link, + render_happ_redirect_page, +) from app.services.tribute_service import TributeService from app.services.payment_service import PaymentService from app.database.database import get_db @@ -35,9 +40,15 @@ class WebhookServer: if settings.is_cryptobot_enabled(): self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler) - + self.app.router.add_get('/health', self._health_check) - + + if settings.is_happ_cryptolink_proxy_enabled(): + proxy_path = settings.get_happ_cryptolink_proxy_path() + self.app.router.add_get(proxy_path, self._happ_cryptolink_handler) + self.app.router.add_head(proxy_path, self._happ_cryptolink_handler) + logger.info(f" - Happ cryptoLink proxy: GET {proxy_path}") + self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler) if settings.is_mulenpay_enabled(): self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler) @@ -50,8 +61,10 @@ class WebhookServer: logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}") if settings.is_cryptobot_enabled(): logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}") + if settings.is_happ_cryptolink_proxy_enabled(): + logger.info(f" - Happ cryptoLink proxy: GET {settings.get_happ_cryptolink_proxy_path()}") logger.info(f" - Health check: GET /health") - + return self.app async def start(self): @@ -177,6 +190,19 @@ class WebhookServer: logger.error("Отсутствует подпись Mulen Pay webhook") return False + async def _happ_cryptolink_handler(self, request: web.Request) -> web.Response: + if request.method == 'HEAD': + return web.Response(status=200) + + token = request.query.get(HAPP_LINK_QUERY_PARAM, "") + happ_link = decode_happ_link(token) + if not happ_link: + logger.warning("Получен некорректный запрос к Happ proxy: %s", request.query_string) + return web.Response(status=400, text="Invalid or missing link") + + html_page = render_happ_redirect_page(happ_link) + return web.Response(text=html_page, content_type='text/html; charset=utf-8') + async def _tribute_webhook_handler(self, request: web.Request) -> web.Response: try: diff --git a/app/utils/happ_links.py b/app/utils/happ_links.py new file mode 100644 index 00000000..75910368 --- /dev/null +++ b/app/utils/happ_links.py @@ -0,0 +1,133 @@ +import base64 +import html +import logging +from typing import Optional + +from app.config import settings + +logger = logging.getLogger(__name__) + +HAPP_LINK_QUERY_PARAM = "data" +_HAPP_SCHEME_PREFIX = "happ://" + + +def _encode_happ_link(link: str) -> str: + encoded = base64.urlsafe_b64encode(link.encode("utf-8")).decode("ascii") + return encoded.rstrip("=") + + +def decode_happ_link(token: str) -> Optional[str]: + if not token: + return None + + padding = "=" * (-len(token) % 4) + try: + decoded = base64.urlsafe_b64decode(f"{token}{padding}".encode("ascii")).decode("utf-8") + except (ValueError, UnicodeDecodeError): + logger.warning("Не удалось декодировать cryptoLink из токена") + return None + + if not decoded.startswith(_HAPP_SCHEME_PREFIX): + logger.warning("Попытка открыть ссылку с неподдерживаемым протоколом: %s", decoded) + return None + + return decoded + + +def build_happ_proxy_link(crypto_link: str) -> Optional[str]: + base_url = settings.get_happ_cryptolink_proxy_base_url() + if not base_url: + logger.error("Не задан базовый URL для прокси Happ cryptoLink") + return None + + path = settings.get_happ_cryptolink_proxy_path() + token = _encode_happ_link(crypto_link) + return f"{base_url}{path}?{HAPP_LINK_QUERY_PARAM}={token}" + + +def render_happ_redirect_page(happ_link: str) -> str: + escaped_link = html.escape(happ_link, quote=True) + return f""" + + + + + Открытие Happ + + + + +
+

Подключение Happ

+

Если приложение Happ не открылось автоматически, нажмите кнопку ниже или скопируйте ссылку вручную.

+ Открыть в Happ +

Ссылка для копирования:
{escaped_link}

+
+ + +""" diff --git a/app/utils/subscription_utils.py b/app/utils/subscription_utils.py index 62db4142..8e30dcb8 100644 --- a/app/utils/subscription_utils.py +++ b/app/utils/subscription_utils.py @@ -5,6 +5,7 @@ from sqlalchemy import select, delete, func from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import Subscription, User from app.config import settings +from app.utils.happ_links import build_happ_proxy_link logger = logging.getLogger(__name__) @@ -106,6 +107,11 @@ def get_display_subscription_link(subscription: Optional[Subscription]) -> Optio if settings.is_happ_cryptolink_mode(): crypto_link = getattr(subscription, "subscription_crypto_link", None) - return crypto_link or base_link + if crypto_link: + proxy_link = build_happ_proxy_link(crypto_link) + if proxy_link: + return proxy_link + logger.warning("Не удалось сформировать прокси-ссылку для Happ, возвращаем стандартную ссылку") + return base_link return base_link diff --git a/main.py b/main.py index 254e5c5d..99a7be23 100644 --- a/main.py +++ b/main.py @@ -126,6 +126,7 @@ async def main(): settings.TRIBUTE_ENABLED or settings.is_cryptobot_enabled() or settings.is_mulenpay_enabled() + or settings.is_happ_cryptolink_proxy_enabled() ) if webhook_needed: @@ -136,7 +137,9 @@ async def main(): enabled_services.append("Mulen Pay") if settings.is_cryptobot_enabled(): enabled_services.append("CryptoBot") - + if settings.is_happ_cryptolink_proxy_enabled(): + enabled_services.append("Happ cryptoLink proxy") + logger.info(f"🌐 Запуск webhook сервера для: {', '.join(enabled_services)}...") webhook_server = WebhookServer(bot) await webhook_server.start() From 6dc515080422882c1521eea9a0f75a5a54d3414f Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:03:49 +0300 Subject: [PATCH 088/146] Revert "Add Happ cryptoLink proxy support" --- .env.example | 2 - app/config.py | 19 ----- app/external/webhook_server.py | 32 +------- app/utils/happ_links.py | 133 -------------------------------- app/utils/subscription_utils.py | 8 +- main.py | 5 +- 6 files changed, 5 insertions(+), 194 deletions(-) delete mode 100644 app/utils/happ_links.py diff --git a/.env.example b/.env.example index e405f914..1070f38a 100644 --- a/.env.example +++ b/.env.example @@ -291,8 +291,6 @@ CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_PC= -HAPP_CRYPTOLINK_PROXY_BASE_URL= -HAPP_CRYPTOLINK_PROXY_PATH=/happ-link # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false diff --git a/app/config.py b/app/config.py index 2d61952f..bace5055 100644 --- a/app/config.py +++ b/app/config.py @@ -210,8 +210,6 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False - HAPP_CRYPTOLINK_PROXY_BASE_URL: Optional[str] = None - HAPP_CRYPTOLINK_PROXY_PATH: str = "/happ-link" HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None HAPP_DOWNLOAD_LINK_PC: Optional[str] = None @@ -549,26 +547,9 @@ class Settings(BaseSettings): def get_cryptobot_invoice_expires_seconds(self) -> int: return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600 - def get_happ_cryptolink_proxy_base_url(self) -> Optional[str]: - base_url = (self.HAPP_CRYPTOLINK_PROXY_BASE_URL or self.WEBHOOK_URL or "").strip() - if not base_url: - return None - return base_url.rstrip('/') - - def get_happ_cryptolink_proxy_path(self) -> str: - path = (self.HAPP_CRYPTOLINK_PROXY_PATH or "").strip() - if not path: - path = "/happ-link" - if not path.startswith('/'): - path = f"/{path}" - return path - def is_happ_cryptolink_mode(self) -> bool: return self.CONNECT_BUTTON_MODE == "happ_cryptolink" - def is_happ_cryptolink_proxy_enabled(self) -> bool: - return self.is_happ_cryptolink_mode() and self.get_happ_cryptolink_proxy_base_url() is not None - def is_happ_download_button_enabled(self) -> bool: return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index 7cef933d..9ea90945 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -8,11 +8,6 @@ from aiohttp import web from aiogram import Bot from app.config import settings -from app.utils.happ_links import ( - HAPP_LINK_QUERY_PARAM, - decode_happ_link, - render_happ_redirect_page, -) from app.services.tribute_service import TributeService from app.services.payment_service import PaymentService from app.database.database import get_db @@ -40,15 +35,9 @@ class WebhookServer: if settings.is_cryptobot_enabled(): self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler) - + self.app.router.add_get('/health', self._health_check) - - if settings.is_happ_cryptolink_proxy_enabled(): - proxy_path = settings.get_happ_cryptolink_proxy_path() - self.app.router.add_get(proxy_path, self._happ_cryptolink_handler) - self.app.router.add_head(proxy_path, self._happ_cryptolink_handler) - logger.info(f" - Happ cryptoLink proxy: GET {proxy_path}") - + self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler) if settings.is_mulenpay_enabled(): self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler) @@ -61,10 +50,8 @@ class WebhookServer: logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}") if settings.is_cryptobot_enabled(): logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}") - if settings.is_happ_cryptolink_proxy_enabled(): - logger.info(f" - Happ cryptoLink proxy: GET {settings.get_happ_cryptolink_proxy_path()}") logger.info(f" - Health check: GET /health") - + return self.app async def start(self): @@ -190,19 +177,6 @@ class WebhookServer: logger.error("Отсутствует подпись Mulen Pay webhook") return False - async def _happ_cryptolink_handler(self, request: web.Request) -> web.Response: - if request.method == 'HEAD': - return web.Response(status=200) - - token = request.query.get(HAPP_LINK_QUERY_PARAM, "") - happ_link = decode_happ_link(token) - if not happ_link: - logger.warning("Получен некорректный запрос к Happ proxy: %s", request.query_string) - return web.Response(status=400, text="Invalid or missing link") - - html_page = render_happ_redirect_page(happ_link) - return web.Response(text=html_page, content_type='text/html; charset=utf-8') - async def _tribute_webhook_handler(self, request: web.Request) -> web.Response: try: diff --git a/app/utils/happ_links.py b/app/utils/happ_links.py deleted file mode 100644 index 75910368..00000000 --- a/app/utils/happ_links.py +++ /dev/null @@ -1,133 +0,0 @@ -import base64 -import html -import logging -from typing import Optional - -from app.config import settings - -logger = logging.getLogger(__name__) - -HAPP_LINK_QUERY_PARAM = "data" -_HAPP_SCHEME_PREFIX = "happ://" - - -def _encode_happ_link(link: str) -> str: - encoded = base64.urlsafe_b64encode(link.encode("utf-8")).decode("ascii") - return encoded.rstrip("=") - - -def decode_happ_link(token: str) -> Optional[str]: - if not token: - return None - - padding = "=" * (-len(token) % 4) - try: - decoded = base64.urlsafe_b64decode(f"{token}{padding}".encode("ascii")).decode("utf-8") - except (ValueError, UnicodeDecodeError): - logger.warning("Не удалось декодировать cryptoLink из токена") - return None - - if not decoded.startswith(_HAPP_SCHEME_PREFIX): - logger.warning("Попытка открыть ссылку с неподдерживаемым протоколом: %s", decoded) - return None - - return decoded - - -def build_happ_proxy_link(crypto_link: str) -> Optional[str]: - base_url = settings.get_happ_cryptolink_proxy_base_url() - if not base_url: - logger.error("Не задан базовый URL для прокси Happ cryptoLink") - return None - - path = settings.get_happ_cryptolink_proxy_path() - token = _encode_happ_link(crypto_link) - return f"{base_url}{path}?{HAPP_LINK_QUERY_PARAM}={token}" - - -def render_happ_redirect_page(happ_link: str) -> str: - escaped_link = html.escape(happ_link, quote=True) - return f""" - - - - - Открытие Happ - - - - -
-

Подключение Happ

-

Если приложение Happ не открылось автоматически, нажмите кнопку ниже или скопируйте ссылку вручную.

- Открыть в Happ -

Ссылка для копирования:
{escaped_link}

-
- - -""" diff --git a/app/utils/subscription_utils.py b/app/utils/subscription_utils.py index 8e30dcb8..62db4142 100644 --- a/app/utils/subscription_utils.py +++ b/app/utils/subscription_utils.py @@ -5,7 +5,6 @@ from sqlalchemy import select, delete, func from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import Subscription, User from app.config import settings -from app.utils.happ_links import build_happ_proxy_link logger = logging.getLogger(__name__) @@ -107,11 +106,6 @@ def get_display_subscription_link(subscription: Optional[Subscription]) -> Optio if settings.is_happ_cryptolink_mode(): crypto_link = getattr(subscription, "subscription_crypto_link", None) - if crypto_link: - proxy_link = build_happ_proxy_link(crypto_link) - if proxy_link: - return proxy_link - logger.warning("Не удалось сформировать прокси-ссылку для Happ, возвращаем стандартную ссылку") - return base_link + return crypto_link or base_link return base_link diff --git a/main.py b/main.py index 99a7be23..254e5c5d 100644 --- a/main.py +++ b/main.py @@ -126,7 +126,6 @@ async def main(): settings.TRIBUTE_ENABLED or settings.is_cryptobot_enabled() or settings.is_mulenpay_enabled() - or settings.is_happ_cryptolink_proxy_enabled() ) if webhook_needed: @@ -137,9 +136,7 @@ async def main(): enabled_services.append("Mulen Pay") if settings.is_cryptobot_enabled(): enabled_services.append("CryptoBot") - if settings.is_happ_cryptolink_proxy_enabled(): - enabled_services.append("Happ cryptoLink proxy") - + logger.info(f"🌐 Запуск webhook сервера для: {', '.join(enabled_services)}...") webhook_server = WebhookServer(bot) await webhook_server.start() From c3846a998a1425fbe6b8e4b00975512e9330fe65 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:04:12 +0300 Subject: [PATCH 089/146] Revert "Revert "Handle Happ cryptolink buttons without unsupported URLs"" --- app/handlers/subscription.py | 164 ++++++++++++++++++++++++++++++----- app/keyboards/inline.py | 51 +++++++++-- 2 files changed, 184 insertions(+), 31 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 8cc84fd3..f76aba7c 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -566,7 +566,11 @@ async def show_subscription_info( subscription_link = get_display_subscription_link(subscription) if subscription_link: - if actual_status in ['trial_active', 'paid_active'] and not settings.HIDE_SUBSCRIPTION_LINK: + if ( + actual_status in ['trial_active', 'paid_active'] + and not settings.HIDE_SUBSCRIPTION_LINK + and not settings.is_happ_cryptolink_mode() + ): message += "\n\n" + texts.t( "SUBSCRIPTION_CONNECT_LINK_SECTION", "🔗 Ссылка для подключения:\n{subscription_url}", @@ -839,16 +843,30 @@ async def activate_trial( subscription_link = get_display_subscription_link(subscription) if remnawave_user and subscription_link: - subscription_import_link = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription_link) + if settings.is_happ_cryptolink_mode(): + trial_success_text = ( + f"{texts.TRIAL_ACTIVATED}\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_LINK_PROMPT", + "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + ) + + "\n\n" + + texts.t( + 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', + '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве', + ) + ) + else: + subscription_import_link = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", + ).format(subscription_url=subscription_link) - trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" - f"{subscription_import_link}\n\n" - f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" - ) + trial_success_text = ( + f"{texts.TRIAL_ACTIVATED}\n\n" + f"{subscription_import_link}\n\n" + f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" + ) connect_mode = settings.CONNECT_BUTTON_MODE @@ -882,7 +900,7 @@ async def activate_trial( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": rows = [ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] ] @@ -896,6 +914,25 @@ async def activate_trial( ) ]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + elif connect_mode == "happ_cryptolink": + rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu" + ) + ]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -3295,16 +3332,30 @@ async def confirm_purchase( subscription_link = get_display_subscription_link(subscription) if remnawave_user and subscription_link: - import_link_section = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", - ).format(subscription_url=subscription_link) + if settings.is_happ_cryptolink_mode(): + success_text = ( + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_LINK_PROMPT", + "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + ) + + "\n\n" + + texts.t( + 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', + '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве', + ) + ) + else: + import_link_section = texts.t( + "SUBSCRIPTION_IMPORT_LINK_SECTION", + "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", + ).format(subscription_url=subscription_link) - success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - f"{import_link_section}\n\n" - f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" - ) + success_text = ( + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + f"{import_link_section}\n\n" + f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" + ) connect_mode = settings.CONNECT_BUTTON_MODE @@ -3338,7 +3389,7 @@ async def confirm_purchase( ], [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], ]) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": rows = [ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] ] @@ -3347,6 +3398,20 @@ async def confirm_purchase( rows.append(happ_row) rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + elif connect_mode == "happ_cryptolink": + rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], @@ -4185,7 +4250,7 @@ async def handle_connect_subscription( parse_mode="HTML" ) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": rows = [ [ InlineKeyboardButton( @@ -4206,14 +4271,41 @@ async def handle_connect_subscription( await callback.message.edit_text( texts.t( "SUBSCRIPTION_CONNECT_LINK_MESSAGE", - """🚀 Подключить подписку + """🚀 Подключить подписку", 🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""", ), reply_markup=keyboard, parse_mode="HTML" ) + elif connect_mode == "happ_cryptolink": + rows = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ] + ] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + + await callback.message.edit_text( + texts.t( + "SUBSCRIPTION_CONNECT_LINK_MESSAGE", + """🚀 Подключить подписку", + +🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""", + ), + reply_markup=keyboard, + parse_mode="HTML" + ) else: device_text = texts.t( "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE", @@ -4514,6 +4606,32 @@ async def handle_open_subscription_link( ) return + if settings.is_happ_cryptolink_mode(): + happ_message = ( + texts.t( + "SUBSCRIPTION_HAPP_OPEN_TITLE", + "🔗 Подключение через Happ", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_OPEN_LINK", + "🔓 Открыть ссылку в Happ", + ).format(subscription_link=subscription_link) + + "\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_OPEN_HINT", + "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", + ).format(subscription_link=subscription_link) + ) + + await callback.message.answer( + happ_message, + parse_mode="HTML", + disable_web_page_preview=True, + ) + await callback.answer() + return + link_text = ( texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + "\n\n" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 29a1262d..68a5a30b 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -113,7 +113,7 @@ def get_main_menu_keyboard( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) ) ]) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": if subscription_link: keyboard.append([ InlineKeyboardButton( @@ -123,6 +123,16 @@ def get_main_menu_keyboard( ]) else: keyboard.append([_fallback_connect_button()]) + elif connect_mode == "happ_cryptolink": + if subscription_link: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ]) + else: + keyboard.append([_fallback_connect_button()]) else: keyboard.append([_fallback_connect_button()]) @@ -385,10 +395,17 @@ def get_subscription_keyboard( keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") ]) - elif connect_mode in {"link", "happ_cryptolink"}: + elif connect_mode == "link": keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link) ]) + elif connect_mode == "happ_cryptolink": + keyboard.append([ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="open_subscription_link", + ) + ]) else: keyboard.append([ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") @@ -1343,9 +1360,18 @@ def get_connection_guide_keyboard( if app_buttons: keyboard.append(app_buttons) - keyboard.append([ - InlineKeyboardButton(text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), url=subscription_url) - ]) + if settings.is_happ_cryptolink_mode(): + copy_button = InlineKeyboardButton( + text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), + callback_data="open_subscription_link", + ) + else: + copy_button = InlineKeyboardButton( + text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), + url=subscription_url, + ) + + keyboard.append([copy_button]) keyboard.extend([ [ @@ -1416,9 +1442,18 @@ def get_specific_app_keyboard( if app_buttons: keyboard.append(app_buttons) - keyboard.append([ - InlineKeyboardButton(text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), url=subscription_url) - ]) + if settings.is_happ_cryptolink_mode(): + copy_button = InlineKeyboardButton( + text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), + callback_data="open_subscription_link", + ) + else: + copy_button = InlineKeyboardButton( + text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), + url=subscription_url, + ) + + keyboard.append([copy_button]) if 'additionalAfterAddSubscriptionStep' in app and 'buttons' in app['additionalAfterAddSubscriptionStep']: for button in app['additionalAfterAddSubscriptionStep']['buttons']: From ccb4dfa267f146a2604c508f9871192e002f5ebb Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:09:31 +0300 Subject: [PATCH 090/146] Add Happ cryptolink subscription translations --- locales/en.json | 3 +++ locales/ru.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/locales/en.json b/locales/en.json index 5b3a6b21..d5e575d8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -404,6 +404,9 @@ "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", + "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Connect via Happ", + "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Open link in Happ", + "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually: {subscription_link}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", diff --git a/locales/ru.json b/locales/ru.json index d1b483af..ac9f1ee6 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -404,6 +404,9 @@ "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", + "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Подключение через Happ", + "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Открыть ссылку в Happ", + "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", From 67c973124a492f2b482e5c830475dccc89215209 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:21:18 +0300 Subject: [PATCH 091/146] Add Happ cryptolink keyboard and Mac download option --- .env.example | 1 + README.md | 1 + app/config.py | 3 ++ app/handlers/subscription.py | 21 +++++++++++-- app/keyboards/inline.py | 54 +++++++++++++++++++++++++++++++- app/localization/locales/en.json | 5 ++- app/localization/locales/ru.json | 5 ++- locales/en.json | 5 ++- locales/ru.json | 5 ++- 9 files changed, 92 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 1070f38a..cf609320 100644 --- a/.env.example +++ b/.env.example @@ -291,6 +291,7 @@ CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_PC= +HAPP_DOWNLOAD_LINK_MAC= # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false diff --git a/README.md b/README.md index a7efc850..35b7def8 100644 --- a/README.md +++ b/README.md @@ -532,6 +532,7 @@ CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_PC= +HAPP_DOWNLOAD_LINK_MAC= # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false diff --git a/app/config.py b/app/config.py index bace5055..a19bd05b 100644 --- a/app/config.py +++ b/app/config.py @@ -213,6 +213,7 @@ class Settings(BaseSettings): HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None HAPP_DOWNLOAD_LINK_PC: Optional[str] = None + HAPP_DOWNLOAD_LINK_MAC: Optional[str] = None HIDE_SUBSCRIPTION_LINK: bool = False ENABLE_LOGO_MODE: bool = True LOGO_FILE: str = "vpn_logo.png" @@ -559,6 +560,8 @@ class Settings(BaseSettings): "ios": (self.HAPP_DOWNLOAD_LINK_IOS or "").strip(), "android": (self.HAPP_DOWNLOAD_LINK_ANDROID or "").strip(), "pc": (self.HAPP_DOWNLOAD_LINK_PC or "").strip(), + "mac": (self.HAPP_DOWNLOAD_LINK_MAC or "").strip(), + "windows": (self.HAPP_DOWNLOAD_LINK_PC or "").strip(), } link = links.get(platform_key) return link if link else None diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index f76aba7c..e47df2a7 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -40,7 +40,7 @@ from app.keyboards.inline import ( get_devices_management_keyboard, get_device_reset_confirm_keyboard, get_device_management_help_keyboard, get_happ_download_platform_keyboard, get_happ_download_link_keyboard, - get_happ_download_button_row, + get_happ_download_button_row, get_happ_subscription_keyboard, get_payment_methods_keyboard_with_cart, get_subscription_confirm_keyboard_with_cart, get_insufficient_balance_keyboard_with_cart @@ -4115,6 +4115,8 @@ async def handle_happ_download_platform_choice( db: AsyncSession ): platform = callback.data.split('_')[-1] + if platform == "pc": + platform = "windows" texts = get_texts(db_user.language) link = settings.get_happ_download_link(platform) @@ -4128,7 +4130,8 @@ async def handle_happ_download_platform_choice( platform_names = { "ios": texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), "android": texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), - "pc": texts.t("HAPP_PLATFORM_PC", "💻 ПК"), + "windows": texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), + "mac": texts.t("HAPP_PLATFORM_MAC", "🍏 Mac OS"), } link_text = texts.t( @@ -4628,6 +4631,10 @@ async def handle_open_subscription_link( happ_message, parse_mode="HTML", disable_web_page_preview=True, + reply_markup=get_happ_subscription_keyboard( + subscription_link, + db_user.language, + ), ) await callback.answer() return @@ -5340,7 +5347,15 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register( handle_happ_download_platform_choice, - F.data.in_(["happ_download_ios", "happ_download_android", "happ_download_pc"]) + F.data.in_( + [ + "happ_download_ios", + "happ_download_android", + "happ_download_windows", + "happ_download_mac", + "happ_download_pc", + ] + ) ) dp.callback_query.register( diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 68a5a30b..41ccf13d 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -254,12 +254,64 @@ def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]: ] +def get_happ_subscription_keyboard( + subscription_link: str, + language: str = DEFAULT_LANGUAGE, +) -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard: List[List[InlineKeyboardButton]] = [ + [ + InlineKeyboardButton( + text=texts.t("SUBSCRIPTION_HAPP_CONNECT_BUTTON", "🔗 Подключиться"), + url=subscription_link, + ) + ] + ] + + if settings.is_happ_download_button_enabled(): + keyboard.extend( + [ + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), + callback_data="happ_download_ios", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), + callback_data="happ_download_android", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), + callback_data="happ_download_windows", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_MAC", "🍏 Mac OS"), + callback_data="happ_download_mac", + ) + ], + ] + ) + + keyboard.append([ + InlineKeyboardButton(text=texts.t("BACK_TO_MENU", "🏠 В главное меню"), callback_data="menu_subscription") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + def get_happ_download_platform_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) buttons = [ [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), callback_data="happ_download_ios")], [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), callback_data="happ_download_android")], - [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_PC", "💻 ПК"), callback_data="happ_download_pc")], + [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), callback_data="happ_download_windows")], + [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_MAC", "🍏 Mac OS"), callback_data="happ_download_mac")], [InlineKeyboardButton(text=texts.BACK, callback_data="happ_download_close")], ] diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 94eb4eca..fa832a39 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -23,10 +23,13 @@ "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", "HAPP_PLATFORM_IOS": "🍎 iOS", "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_PC": "💻 PC", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "HAPP_PLATFORM_MAC": "🍏 Mac OS", + "HAPP_PLATFORM_PC": "💻 Windows", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", + "SUBSCRIPTION_HAPP_CONNECT_BUTTON": "🔗 Connect", "CONTINUE": "➡️ Continue", "CONTINUE_BUTTON": "➡️ Continue", "COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 5f0fad3d..5d4dfb77 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -103,10 +103,13 @@ "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", "HAPP_PLATFORM_IOS": "🍎 iOS", "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_PC": "💻 ПК", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "HAPP_PLATFORM_MAC": "🍏 Mac OS", + "HAPP_PLATFORM_PC": "💻 Windows", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", + "SUBSCRIPTION_HAPP_CONNECT_BUTTON": "🔗 Подключиться", "CONTACT_SUPPORT": "💬 Написать в поддержку", "CONTINUE": "➡️ Продолжить", "CONTINUE_BUTTON": "✅ Продолжить", diff --git a/locales/en.json b/locales/en.json index d5e575d8..a618516f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -24,7 +24,9 @@ "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", "HAPP_PLATFORM_IOS": "🍎 iOS", "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_PC": "💻 PC", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "HAPP_PLATFORM_MAC": "🍏 Mac OS", + "HAPP_PLATFORM_PC": "💻 Windows", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", @@ -407,6 +409,7 @@ "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Connect via Happ", "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Open link in Happ", "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually: {subscription_link}", + "SUBSCRIPTION_HAPP_CONNECT_BUTTON": "🔗 Connect", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", diff --git a/locales/ru.json b/locales/ru.json index ac9f1ee6..cf286432 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -187,7 +187,9 @@ "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", "HAPP_PLATFORM_IOS": "🍎 iOS", "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_PC": "💻 ПК", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "HAPP_PLATFORM_MAC": "🍏 Mac OS", + "HAPP_PLATFORM_PC": "💻 Windows", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", @@ -407,6 +409,7 @@ "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Подключение через Happ", "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Открыть ссылку в Happ", "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", + "SUBSCRIPTION_HAPP_CONNECT_BUTTON": "🔗 Подключиться", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", From 814e38ef594c8ab9108b9e749340673be7522791 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:22:23 +0300 Subject: [PATCH 092/146] Revert "Add Happ cryptolink keyboard and Mac download option" --- .env.example | 1 - README.md | 1 - app/config.py | 3 -- app/handlers/subscription.py | 21 ++----------- app/keyboards/inline.py | 54 +------------------------------- app/localization/locales/en.json | 5 +-- app/localization/locales/ru.json | 5 +-- locales/en.json | 5 +-- locales/ru.json | 5 +-- 9 files changed, 8 insertions(+), 92 deletions(-) diff --git a/.env.example b/.env.example index cf609320..1070f38a 100644 --- a/.env.example +++ b/.env.example @@ -291,7 +291,6 @@ CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_PC= -HAPP_DOWNLOAD_LINK_MAC= # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false diff --git a/README.md b/README.md index 35b7def8..a7efc850 100644 --- a/README.md +++ b/README.md @@ -532,7 +532,6 @@ CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_PC= -HAPP_DOWNLOAD_LINK_MAC= # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false diff --git a/app/config.py b/app/config.py index a19bd05b..bace5055 100644 --- a/app/config.py +++ b/app/config.py @@ -213,7 +213,6 @@ class Settings(BaseSettings): HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None HAPP_DOWNLOAD_LINK_PC: Optional[str] = None - HAPP_DOWNLOAD_LINK_MAC: Optional[str] = None HIDE_SUBSCRIPTION_LINK: bool = False ENABLE_LOGO_MODE: bool = True LOGO_FILE: str = "vpn_logo.png" @@ -560,8 +559,6 @@ class Settings(BaseSettings): "ios": (self.HAPP_DOWNLOAD_LINK_IOS or "").strip(), "android": (self.HAPP_DOWNLOAD_LINK_ANDROID or "").strip(), "pc": (self.HAPP_DOWNLOAD_LINK_PC or "").strip(), - "mac": (self.HAPP_DOWNLOAD_LINK_MAC or "").strip(), - "windows": (self.HAPP_DOWNLOAD_LINK_PC or "").strip(), } link = links.get(platform_key) return link if link else None diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index e47df2a7..f76aba7c 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -40,7 +40,7 @@ from app.keyboards.inline import ( get_devices_management_keyboard, get_device_reset_confirm_keyboard, get_device_management_help_keyboard, get_happ_download_platform_keyboard, get_happ_download_link_keyboard, - get_happ_download_button_row, get_happ_subscription_keyboard, + get_happ_download_button_row, get_payment_methods_keyboard_with_cart, get_subscription_confirm_keyboard_with_cart, get_insufficient_balance_keyboard_with_cart @@ -4115,8 +4115,6 @@ async def handle_happ_download_platform_choice( db: AsyncSession ): platform = callback.data.split('_')[-1] - if platform == "pc": - platform = "windows" texts = get_texts(db_user.language) link = settings.get_happ_download_link(platform) @@ -4130,8 +4128,7 @@ async def handle_happ_download_platform_choice( platform_names = { "ios": texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), "android": texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), - "windows": texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), - "mac": texts.t("HAPP_PLATFORM_MAC", "🍏 Mac OS"), + "pc": texts.t("HAPP_PLATFORM_PC", "💻 ПК"), } link_text = texts.t( @@ -4631,10 +4628,6 @@ async def handle_open_subscription_link( happ_message, parse_mode="HTML", disable_web_page_preview=True, - reply_markup=get_happ_subscription_keyboard( - subscription_link, - db_user.language, - ), ) await callback.answer() return @@ -5347,15 +5340,7 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register( handle_happ_download_platform_choice, - F.data.in_( - [ - "happ_download_ios", - "happ_download_android", - "happ_download_windows", - "happ_download_mac", - "happ_download_pc", - ] - ) + F.data.in_(["happ_download_ios", "happ_download_android", "happ_download_pc"]) ) dp.callback_query.register( diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 41ccf13d..68a5a30b 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -254,64 +254,12 @@ def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]: ] -def get_happ_subscription_keyboard( - subscription_link: str, - language: str = DEFAULT_LANGUAGE, -) -> InlineKeyboardMarkup: - texts = get_texts(language) - keyboard: List[List[InlineKeyboardButton]] = [ - [ - InlineKeyboardButton( - text=texts.t("SUBSCRIPTION_HAPP_CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription_link, - ) - ] - ] - - if settings.is_happ_download_button_enabled(): - keyboard.extend( - [ - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), - callback_data="happ_download_ios", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), - callback_data="happ_download_android", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), - callback_data="happ_download_windows", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_MAC", "🍏 Mac OS"), - callback_data="happ_download_mac", - ) - ], - ] - ) - - keyboard.append([ - InlineKeyboardButton(text=texts.t("BACK_TO_MENU", "🏠 В главное меню"), callback_data="menu_subscription") - ]) - - return InlineKeyboardMarkup(inline_keyboard=keyboard) - - def get_happ_download_platform_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) buttons = [ [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), callback_data="happ_download_ios")], [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), callback_data="happ_download_android")], - [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), callback_data="happ_download_windows")], - [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_MAC", "🍏 Mac OS"), callback_data="happ_download_mac")], + [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_PC", "💻 ПК"), callback_data="happ_download_pc")], [InlineKeyboardButton(text=texts.BACK, callback_data="happ_download_close")], ] diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index fa832a39..94eb4eca 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -23,13 +23,10 @@ "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", "HAPP_PLATFORM_IOS": "🍎 iOS", "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_WINDOWS": "💻 Windows", - "HAPP_PLATFORM_MAC": "🍏 Mac OS", - "HAPP_PLATFORM_PC": "💻 Windows", + "HAPP_PLATFORM_PC": "💻 PC", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", - "SUBSCRIPTION_HAPP_CONNECT_BUTTON": "🔗 Connect", "CONTINUE": "➡️ Continue", "CONTINUE_BUTTON": "➡️ Continue", "COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 5d4dfb77..5f0fad3d 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -103,13 +103,10 @@ "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", "HAPP_PLATFORM_IOS": "🍎 iOS", "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_WINDOWS": "💻 Windows", - "HAPP_PLATFORM_MAC": "🍏 Mac OS", - "HAPP_PLATFORM_PC": "💻 Windows", + "HAPP_PLATFORM_PC": "💻 ПК", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", - "SUBSCRIPTION_HAPP_CONNECT_BUTTON": "🔗 Подключиться", "CONTACT_SUPPORT": "💬 Написать в поддержку", "CONTINUE": "➡️ Продолжить", "CONTINUE_BUTTON": "✅ Продолжить", diff --git a/locales/en.json b/locales/en.json index a618516f..d5e575d8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -24,9 +24,7 @@ "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", "HAPP_PLATFORM_IOS": "🍎 iOS", "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_WINDOWS": "💻 Windows", - "HAPP_PLATFORM_MAC": "🍏 Mac OS", - "HAPP_PLATFORM_PC": "💻 Windows", + "HAPP_PLATFORM_PC": "💻 PC", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", @@ -409,7 +407,6 @@ "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Connect via Happ", "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Open link in Happ", "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually: {subscription_link}", - "SUBSCRIPTION_HAPP_CONNECT_BUTTON": "🔗 Connect", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", diff --git a/locales/ru.json b/locales/ru.json index cf286432..ac9f1ee6 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -187,9 +187,7 @@ "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", "HAPP_PLATFORM_IOS": "🍎 iOS", "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_WINDOWS": "💻 Windows", - "HAPP_PLATFORM_MAC": "🍏 Mac OS", - "HAPP_PLATFORM_PC": "💻 Windows", + "HAPP_PLATFORM_PC": "💻 ПК", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", @@ -409,7 +407,6 @@ "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Подключение через Happ", "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Открыть ссылку в Happ", "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", - "SUBSCRIPTION_HAPP_CONNECT_BUTTON": "🔗 Подключиться", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", From 4ad53ca7623bdfd899acd984480b7363c85a868c Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:23:03 +0300 Subject: [PATCH 093/146] Add Happ cryptolink keyboard and update download options --- .env.example | 3 ++ README.md | 3 ++ app/config.py | 12 +++++++- app/handlers/subscription.py | 17 +++++++++-- app/keyboards/inline.py | 50 +++++++++++++++++++++++++++++++- app/localization/locales/en.json | 2 ++ app/localization/locales/ru.json | 2 ++ locales/en.json | 2 ++ locales/ru.json | 2 ++ 9 files changed, 89 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 1070f38a..747f289c 100644 --- a/.env.example +++ b/.env.example @@ -290,6 +290,9 @@ MINIAPP_CUSTOM_URL= CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= +HAPP_DOWNLOAD_LINK_MACOS= +HAPP_DOWNLOAD_LINK_WINDOWS= +# (опционально) устаревшее поле для Windows, будет использовано если HAPP_DOWNLOAD_LINK_WINDOWS не задан HAPP_DOWNLOAD_LINK_PC= # Пропустить принятие правил использования бота diff --git a/README.md b/README.md index a7efc850..fb7f037d 100644 --- a/README.md +++ b/README.md @@ -531,6 +531,9 @@ MINIAPP_CUSTOM_URL= CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= +HAPP_DOWNLOAD_LINK_MACOS= +HAPP_DOWNLOAD_LINK_WINDOWS= +# (опционально) устаревшее поле для Windows, будет использовано если HAPP_DOWNLOAD_LINK_WINDOWS не задан HAPP_DOWNLOAD_LINK_PC= # Пропустить принятие правил использования бота diff --git a/app/config.py b/app/config.py index bace5055..6d16054c 100644 --- a/app/config.py +++ b/app/config.py @@ -212,6 +212,8 @@ class Settings(BaseSettings): CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None + HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None + HAPP_DOWNLOAD_LINK_WINDOWS: Optional[str] = None HAPP_DOWNLOAD_LINK_PC: Optional[str] = None HIDE_SUBSCRIPTION_LINK: bool = False ENABLE_LOGO_MODE: bool = True @@ -555,10 +557,18 @@ class Settings(BaseSettings): def get_happ_download_link(self, platform: str) -> Optional[str]: platform_key = platform.lower() + + if platform_key == "pc": + platform_key = "windows" + links = { "ios": (self.HAPP_DOWNLOAD_LINK_IOS or "").strip(), "android": (self.HAPP_DOWNLOAD_LINK_ANDROID or "").strip(), - "pc": (self.HAPP_DOWNLOAD_LINK_PC or "").strip(), + "macos": (self.HAPP_DOWNLOAD_LINK_MACOS or "").strip(), + "windows": ( + (self.HAPP_DOWNLOAD_LINK_WINDOWS or "").strip() + or (self.HAPP_DOWNLOAD_LINK_PC or "").strip() + ), } link = links.get(platform_key) return link if link else None diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index f76aba7c..e6dab937 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -39,6 +39,7 @@ from app.keyboards.inline import ( get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, get_devices_management_keyboard, get_device_reset_confirm_keyboard, get_device_management_help_keyboard, + get_happ_cryptolink_keyboard, get_happ_download_platform_keyboard, get_happ_download_link_keyboard, get_happ_download_button_row, get_payment_methods_keyboard_with_cart, @@ -4115,6 +4116,8 @@ async def handle_happ_download_platform_choice( db: AsyncSession ): platform = callback.data.split('_')[-1] + if platform == "pc": + platform = "windows" texts = get_texts(db_user.language) link = settings.get_happ_download_link(platform) @@ -4128,7 +4131,8 @@ async def handle_happ_download_platform_choice( platform_names = { "ios": texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), "android": texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), - "pc": texts.t("HAPP_PLATFORM_PC", "💻 ПК"), + "macos": texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), + "windows": texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), } link_text = texts.t( @@ -4624,10 +4628,13 @@ async def handle_open_subscription_link( ).format(subscription_link=subscription_link) ) + keyboard = get_happ_cryptolink_keyboard(subscription_link, db_user.language) + await callback.message.answer( happ_message, parse_mode="HTML", disable_web_page_preview=True, + reply_markup=keyboard, ) await callback.answer() return @@ -5340,7 +5347,13 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register( handle_happ_download_platform_choice, - F.data.in_(["happ_download_ios", "happ_download_android", "happ_download_pc"]) + F.data.in_([ + "happ_download_ios", + "happ_download_android", + "happ_download_pc", + "happ_download_macos", + "happ_download_windows", + ]) ) dp.callback_query.register( diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 68a5a30b..01e79ce4 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -254,12 +254,60 @@ def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]: ] +def get_happ_cryptolink_keyboard( + subscription_link: str, + language: str = DEFAULT_LANGUAGE, +) -> InlineKeyboardMarkup: + texts = get_texts(language) + buttons = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=subscription_link, + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), + callback_data="happ_download_ios", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), + callback_data="happ_download_android", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), + callback_data="happ_download_macos", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), + callback_data="happ_download_windows", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu", + ) + ], + ] + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + def get_happ_download_platform_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) buttons = [ [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), callback_data="happ_download_ios")], [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), callback_data="happ_download_android")], - [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_PC", "💻 ПК"), callback_data="happ_download_pc")], + [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), callback_data="happ_download_macos")], + [InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), callback_data="happ_download_windows")], [InlineKeyboardButton(text=texts.BACK, callback_data="happ_download_close")], ] diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 94eb4eca..98b41a5b 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -23,6 +23,8 @@ "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", "HAPP_PLATFORM_IOS": "🍎 iOS", "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", "HAPP_PLATFORM_PC": "💻 PC", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 5f0fad3d..831a55d1 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -103,6 +103,8 @@ "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", "HAPP_PLATFORM_IOS": "🍎 iOS", "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", "HAPP_PLATFORM_PC": "💻 ПК", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", diff --git a/locales/en.json b/locales/en.json index d5e575d8..84a68c61 100644 --- a/locales/en.json +++ b/locales/en.json @@ -24,6 +24,8 @@ "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", "HAPP_PLATFORM_IOS": "🍎 iOS", "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", "HAPP_PLATFORM_PC": "💻 PC", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", diff --git a/locales/ru.json b/locales/ru.json index ac9f1ee6..b1498e4e 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -187,6 +187,8 @@ "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", "HAPP_PLATFORM_IOS": "🍎 iOS", "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", "HAPP_PLATFORM_PC": "💻 ПК", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", From 10c3151d9ecaba75d35c623bf768a6b408a1fd51 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:34:37 +0300 Subject: [PATCH 094/146] Handle non-http Happ subscription links --- app/keyboards/inline.py | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 01e79ce4..084d31e5 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1,4 +1,5 @@ from typing import List, Optional +from urllib.parse import urlparse from aiogram import types from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from datetime import datetime @@ -14,6 +15,21 @@ import logging logger = logging.getLogger(__name__) + +_SUPPORTED_INLINE_URL_SCHEMES = {"http", "https", "tg"} + + +def _is_supported_inline_url(url: str) -> bool: + if not url: + return False + + try: + parsed = urlparse(url) + except ValueError: + return False + + return parsed.scheme.lower() in _SUPPORTED_INLINE_URL_SCHEMES + def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ @@ -259,13 +275,22 @@ def get_happ_cryptolink_keyboard( language: str = DEFAULT_LANGUAGE, ) -> InlineKeyboardMarkup: texts = get_texts(language) - buttons = [ - [ + buttons: List[List[InlineKeyboardButton]] = [] + + if _is_supported_inline_url(subscription_link): + buttons.append([ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link, ) - ], + ]) + else: + logger.debug( + "Unsupported subscription link scheme for inline button: %s", + subscription_link, + ) + + buttons.extend([ [ InlineKeyboardButton( text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), @@ -296,7 +321,7 @@ def get_happ_cryptolink_keyboard( callback_data="back_to_menu", ) ], - ] + ]) return InlineKeyboardMarkup(inline_keyboard=buttons) From 565e6d963b811a4ea3b351e7c8bf23771eaaf8ba Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:36:07 +0300 Subject: [PATCH 095/146] Revert "Handle non-http Happ subscription links" --- app/keyboards/inline.py | 33 ++++----------------------------- 1 file changed, 4 insertions(+), 29 deletions(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 084d31e5..01e79ce4 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1,5 +1,4 @@ from typing import List, Optional -from urllib.parse import urlparse from aiogram import types from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from datetime import datetime @@ -15,21 +14,6 @@ import logging logger = logging.getLogger(__name__) - -_SUPPORTED_INLINE_URL_SCHEMES = {"http", "https", "tg"} - - -def _is_supported_inline_url(url: str) -> bool: - if not url: - return False - - try: - parsed = urlparse(url) - except ValueError: - return False - - return parsed.scheme.lower() in _SUPPORTED_INLINE_URL_SCHEMES - def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ @@ -275,22 +259,13 @@ def get_happ_cryptolink_keyboard( language: str = DEFAULT_LANGUAGE, ) -> InlineKeyboardMarkup: texts = get_texts(language) - buttons: List[List[InlineKeyboardButton]] = [] - - if _is_supported_inline_url(subscription_link): - buttons.append([ + buttons = [ + [ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link, ) - ]) - else: - logger.debug( - "Unsupported subscription link scheme for inline button: %s", - subscription_link, - ) - - buttons.extend([ + ], [ InlineKeyboardButton( text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), @@ -321,7 +296,7 @@ def get_happ_cryptolink_keyboard( callback_data="back_to_menu", ) ], - ]) + ] return InlineKeyboardMarkup(inline_keyboard=buttons) From 0d714b4dff6d837626ca82d6f135fe02cd1db37e Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:36:26 +0300 Subject: [PATCH 096/146] Handle Happ cryptolink without unsupported Telegram URL --- app/handlers/subscription.py | 52 +++++++++++++------ app/keyboards/inline.py | 91 ++++++++++++++++++--------------- app/utils/subscription_utils.py | 21 +++++++- locales/en.json | 1 + locales/ru.json | 1 + 5 files changed, 108 insertions(+), 58 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index e6dab937..6f27d2db 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -1,17 +1,19 @@ -import logging -from datetime import datetime, timedelta -from aiogram import Dispatcher, types, F -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from aiogram.fsm.context import FSMContext -from sqlalchemy.ext.asyncio import AsyncSession +import html import json +import logging import os -from typing import Dict, List, Any, Tuple, Optional +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple -from app.config import settings, PERIOD_PRICES, get_traffic_prices +from aiogram import F, Dispatcher, types +from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import PERIOD_PRICES, get_traffic_prices, settings from app.states import SubscriptionStates from app.database.crud.subscription import ( - get_subscription_by_user_id, create_trial_subscription, + get_subscription_by_user_id, create_trial_subscription, create_paid_subscription, extend_subscription, add_subscription_traffic, add_subscription_devices, add_subscription_squad, update_subscription_autopay, @@ -64,7 +66,10 @@ from app.utils.pricing_utils import ( format_period_description, ) from app.utils.pagination import paginate_list -from app.utils.subscription_utils import get_display_subscription_link +from app.utils.subscription_utils import ( + get_display_subscription_link, + is_supported_telegram_url, +) logger = logging.getLogger(__name__) @@ -4611,21 +4616,34 @@ async def handle_open_subscription_link( return if settings.is_happ_cryptolink_mode(): + escaped_subscription_link = html.escape(subscription_link) + can_open_directly = is_supported_telegram_url(subscription_link) + + link_line = texts.t( + "SUBSCRIPTION_HAPP_OPEN_LINK", + "🔓 Открыть ссылку в Happ", + ) + + if can_open_directly: + formatted_link_line = link_line.format(subscription_link=subscription_link) + else: + formatted_link_line = texts.t( + "SUBSCRIPTION_HAPP_OPEN_COPY", + "🔓 Скопируйте ссылку и откройте в Happ: {subscription_link}", + ).format(subscription_link=escaped_subscription_link) + happ_message = ( texts.t( "SUBSCRIPTION_HAPP_OPEN_TITLE", "🔗 Подключение через Happ", ) + "\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_OPEN_LINK", - "🔓 Открыть ссылку в Happ", - ).format(subscription_link=subscription_link) + + formatted_link_line + "\n\n" + texts.t( "SUBSCRIPTION_HAPP_OPEN_HINT", "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", - ).format(subscription_link=subscription_link) + ).format(subscription_link=escaped_subscription_link) ) keyboard = get_happ_cryptolink_keyboard(subscription_link, db_user.language) @@ -4639,10 +4657,12 @@ async def handle_open_subscription_link( await callback.answer() return + escaped_subscription_link = html.escape(subscription_link) + link_text = ( texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + "\n\n" - + f"{subscription_link}\n\n" + + f"{escaped_subscription_link}\n\n" + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:") + "\n" + "\n".join( diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 01e79ce4..92cd73b6 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1,16 +1,21 @@ from typing import List, Optional -from aiogram import types -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + +import logging from datetime import datetime -from app.database.models import User + +from aiogram import types +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES +from app.config import PERIOD_PRICES, TRAFFIC_PRICES, settings +from app.database.models import User from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts from app.utils.pricing_utils import format_period_description -from app.utils.subscription_utils import get_display_subscription_link -import logging +from app.utils.subscription_utils import ( + get_display_subscription_link, + is_supported_telegram_url, +) logger = logging.getLogger(__name__) @@ -255,48 +260,54 @@ def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]: def get_happ_cryptolink_keyboard( - subscription_link: str, + subscription_link: Optional[str], language: str = DEFAULT_LANGUAGE, ) -> InlineKeyboardMarkup: texts = get_texts(language) - buttons = [ - [ + buttons: List[List[InlineKeyboardButton]] = [] + + if is_supported_telegram_url(subscription_link): + buttons.append([ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link, ) - ], + ]) + + buttons.extend( [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), - callback_data="happ_download_ios", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), - callback_data="happ_download_android", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), - callback_data="happ_download_macos", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), - callback_data="happ_download_windows", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ], - ] + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), + callback_data="happ_download_ios", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), + callback_data="happ_download_android", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), + callback_data="happ_download_macos", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), + callback_data="happ_download_windows", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu", + ) + ], + ] + ) return InlineKeyboardMarkup(inline_keyboard=buttons) diff --git a/app/utils/subscription_utils.py b/app/utils/subscription_utils.py index 62db4142..f26bd9f2 100644 --- a/app/utils/subscription_utils.py +++ b/app/utils/subscription_utils.py @@ -1,10 +1,13 @@ import logging from datetime import datetime from typing import Optional -from sqlalchemy import select, delete, func +from urllib.parse import urlsplit + +from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import Subscription, User + from app.config import settings +from app.database.models import Subscription, User logger = logging.getLogger(__name__) @@ -109,3 +112,17 @@ def get_display_subscription_link(subscription: Optional[Subscription]) -> Optio return crypto_link or base_link return base_link + + +def is_supported_telegram_url(url: Optional[str]) -> bool: + """Check whether Telegram allows using the given URL in inline buttons.""" + + if not url: + return False + + try: + scheme = urlsplit(url).scheme + except ValueError: + return False + + return scheme in {"http", "https", "tg", "ton"} diff --git a/locales/en.json b/locales/en.json index 84a68c61..c38bfe69 100644 --- a/locales/en.json +++ b/locales/en.json @@ -408,6 +408,7 @@ "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Connect via Happ", "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Open link in Happ", + "SUBSCRIPTION_HAPP_OPEN_COPY": "🔓 Copy the link and open it in Happ: {subscription_link}", "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually: {subscription_link}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", diff --git a/locales/ru.json b/locales/ru.json index b1498e4e..6b785bbc 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -408,6 +408,7 @@ "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Подключение через Happ", "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Открыть ссылку в Happ", + "SUBSCRIPTION_HAPP_OPEN_COPY": "🔓 Скопируйте ссылку и откройте в Happ: {subscription_link}", "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", From 25960c180fc3c5d5cb584eb0219189aa838576e8 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:37:40 +0300 Subject: [PATCH 097/146] Revert "Handle Happ cryptolink without unsupported Telegram URL" --- app/handlers/subscription.py | 48 +++++------------ app/keyboards/inline.py | 91 +++++++++++++++------------------ app/utils/subscription_utils.py | 21 +------- locales/en.json | 1 - locales/ru.json | 1 - 5 files changed, 56 insertions(+), 106 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 6f27d2db..e6dab937 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -1,19 +1,17 @@ -import html -import json import logging -import os from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Tuple - -from aiogram import F, Dispatcher, types +from aiogram import Dispatcher, types, F +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession +import json +import os +from typing import Dict, List, Any, Tuple, Optional -from app.config import PERIOD_PRICES, get_traffic_prices, settings +from app.config import settings, PERIOD_PRICES, get_traffic_prices from app.states import SubscriptionStates from app.database.crud.subscription import ( - get_subscription_by_user_id, create_trial_subscription, + get_subscription_by_user_id, create_trial_subscription, create_paid_subscription, extend_subscription, add_subscription_traffic, add_subscription_devices, add_subscription_squad, update_subscription_autopay, @@ -66,10 +64,7 @@ from app.utils.pricing_utils import ( format_period_description, ) from app.utils.pagination import paginate_list -from app.utils.subscription_utils import ( - get_display_subscription_link, - is_supported_telegram_url, -) +from app.utils.subscription_utils import get_display_subscription_link logger = logging.getLogger(__name__) @@ -4616,34 +4611,21 @@ async def handle_open_subscription_link( return if settings.is_happ_cryptolink_mode(): - escaped_subscription_link = html.escape(subscription_link) - can_open_directly = is_supported_telegram_url(subscription_link) - - link_line = texts.t( - "SUBSCRIPTION_HAPP_OPEN_LINK", - "🔓 Открыть ссылку в Happ", - ) - - if can_open_directly: - formatted_link_line = link_line.format(subscription_link=subscription_link) - else: - formatted_link_line = texts.t( - "SUBSCRIPTION_HAPP_OPEN_COPY", - "🔓 Скопируйте ссылку и откройте в Happ: {subscription_link}", - ).format(subscription_link=escaped_subscription_link) - happ_message = ( texts.t( "SUBSCRIPTION_HAPP_OPEN_TITLE", "🔗 Подключение через Happ", ) + "\n\n" - + formatted_link_line + + texts.t( + "SUBSCRIPTION_HAPP_OPEN_LINK", + "🔓 Открыть ссылку в Happ", + ).format(subscription_link=subscription_link) + "\n\n" + texts.t( "SUBSCRIPTION_HAPP_OPEN_HINT", "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", - ).format(subscription_link=escaped_subscription_link) + ).format(subscription_link=subscription_link) ) keyboard = get_happ_cryptolink_keyboard(subscription_link, db_user.language) @@ -4657,12 +4639,10 @@ async def handle_open_subscription_link( await callback.answer() return - escaped_subscription_link = html.escape(subscription_link) - link_text = ( texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + "\n\n" - + f"{escaped_subscription_link}\n\n" + + f"{subscription_link}\n\n" + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:") + "\n" + "\n".join( diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 92cd73b6..01e79ce4 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1,21 +1,16 @@ from typing import List, Optional - -import logging -from datetime import datetime - from aiogram import types -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from datetime import datetime +from app.database.models import User from sqlalchemy.ext.asyncio import AsyncSession -from app.config import PERIOD_PRICES, TRAFFIC_PRICES, settings -from app.database.models import User +from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts from app.utils.pricing_utils import format_period_description -from app.utils.subscription_utils import ( - get_display_subscription_link, - is_supported_telegram_url, -) +from app.utils.subscription_utils import get_display_subscription_link +import logging logger = logging.getLogger(__name__) @@ -260,54 +255,48 @@ def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]: def get_happ_cryptolink_keyboard( - subscription_link: Optional[str], + subscription_link: str, language: str = DEFAULT_LANGUAGE, ) -> InlineKeyboardMarkup: texts = get_texts(language) - buttons: List[List[InlineKeyboardButton]] = [] - - if is_supported_telegram_url(subscription_link): - buttons.append([ + buttons = [ + [ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link, ) - ]) - - buttons.extend( + ], [ - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), - callback_data="happ_download_ios", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), - callback_data="happ_download_android", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), - callback_data="happ_download_macos", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), - callback_data="happ_download_windows", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ], - ] - ) + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), + callback_data="happ_download_ios", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), + callback_data="happ_download_android", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), + callback_data="happ_download_macos", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), + callback_data="happ_download_windows", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu", + ) + ], + ] return InlineKeyboardMarkup(inline_keyboard=buttons) diff --git a/app/utils/subscription_utils.py b/app/utils/subscription_utils.py index f26bd9f2..62db4142 100644 --- a/app/utils/subscription_utils.py +++ b/app/utils/subscription_utils.py @@ -1,13 +1,10 @@ import logging from datetime import datetime from typing import Optional -from urllib.parse import urlsplit - -from sqlalchemy import delete, func, select +from sqlalchemy import select, delete, func from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings from app.database.models import Subscription, User +from app.config import settings logger = logging.getLogger(__name__) @@ -112,17 +109,3 @@ def get_display_subscription_link(subscription: Optional[Subscription]) -> Optio return crypto_link or base_link return base_link - - -def is_supported_telegram_url(url: Optional[str]) -> bool: - """Check whether Telegram allows using the given URL in inline buttons.""" - - if not url: - return False - - try: - scheme = urlsplit(url).scheme - except ValueError: - return False - - return scheme in {"http", "https", "tg", "ton"} diff --git a/locales/en.json b/locales/en.json index c38bfe69..84a68c61 100644 --- a/locales/en.json +++ b/locales/en.json @@ -408,7 +408,6 @@ "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Connect via Happ", "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Open link in Happ", - "SUBSCRIPTION_HAPP_OPEN_COPY": "🔓 Copy the link and open it in Happ: {subscription_link}", "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually: {subscription_link}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", diff --git a/locales/ru.json b/locales/ru.json index 6b785bbc..b1498e4e 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -408,7 +408,6 @@ "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Подключение через Happ", "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Открыть ссылку в Happ", - "SUBSCRIPTION_HAPP_OPEN_COPY": "🔓 Скопируйте ссылку и откройте в Happ: {subscription_link}", "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", From 9c0e489f3b422edc722fe8aa2c32ea05f73a514a Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:38:19 +0300 Subject: [PATCH 098/146] Fix Happ cryptolink button to avoid unsupported URL --- app/handlers/subscription.py | 67 ++++++++++++++++++++++++--- app/keyboards/inline.py | 88 +++++++++++++++++++++++------------- locales/en.json | 5 ++ locales/ru.json | 5 ++ 4 files changed, 128 insertions(+), 37 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index e6dab937..a98570e8 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -4589,11 +4589,45 @@ async def handle_no_traffic_packages( ): await callback.answer( "⚠️ В данный момент нет доступных пакетов трафика. " - "Обратитесь в техподдержку для получения информации.", + "Обратитесь в техподдержку для получения информации.", show_alert=True ) +async def handle_happ_copy_subscription_link( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + texts = get_texts(db_user.language) + subscription = db_user.subscription + subscription_link = get_display_subscription_link(subscription) + + if not subscription_link: + await callback.answer( + texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"), + show_alert=True, + ) + return + + await callback.message.answer( + texts.t( + "SUBSCRIPTION_HAPP_COPY_MESSAGE", + "🔗 Ссылка для Happ:\n{subscription_link}", + ).format(subscription_link=subscription_link), + parse_mode="HTML", + disable_web_page_preview=True, + ) + + await callback.answer( + texts.t( + "SUBSCRIPTION_HAPP_COPY_ALERT", + "📋 Ссылка отправлена отдельным сообщением.", + ), + show_alert=False, + ) + + async def handle_open_subscription_link( callback: types.CallbackQuery, db_user: User, @@ -4611,21 +4645,37 @@ async def handle_open_subscription_link( return if settings.is_happ_cryptolink_mode(): + allowed_schemes = ("http://", "https://", "tg://", "ton://", "ftp://") + link_supported = subscription_link.startswith(allowed_schemes) + + if link_supported: + link_line = texts.t( + "SUBSCRIPTION_HAPP_OPEN_LINK", + "🔓 Открыть ссылку в Happ", + ).format(subscription_link=subscription_link) + else: + link_line = texts.t( + "SUBSCRIPTION_HAPP_OPEN_LINK_UNSUPPORTED", + "🔓 Скопируйте ссылку из блока ниже и откройте её в Happ вручную.", + ) + happ_message = ( texts.t( "SUBSCRIPTION_HAPP_OPEN_TITLE", "🔗 Подключение через Happ", ) + "\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_OPEN_LINK", - "🔓 Открыть ссылку в Happ", - ).format(subscription_link=subscription_link) + + link_line + "\n\n" + texts.t( "SUBSCRIPTION_HAPP_OPEN_HINT", "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", ).format(subscription_link=subscription_link) + + "\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_OPEN_COPY_HINT", + "📋 Нажмите кнопку ниже, чтобы получить ссылку одним нажатием.", + ) ) keyboard = get_happ_cryptolink_keyboard(subscription_link, db_user.language) @@ -5366,11 +5416,16 @@ def register_handlers(dp: Dispatcher): F.data == "happ_download_back" ) + dp.callback_query.register( + handle_happ_copy_subscription_link, + F.data == "happ_copy_link" + ) + dp.callback_query.register( handle_connect_subscription, F.data == "subscription_connect" ) - + dp.callback_query.register( handle_device_guide, F.data.startswith("device_guide_") diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 01e79ce4..92b4345b 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -2,6 +2,7 @@ from typing import List, Optional from aiogram import types from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from datetime import datetime +from urllib.parse import urlparse from app.database.models import User from sqlalchemy.ext.asyncio import AsyncSession @@ -254,49 +255,74 @@ def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]: ] +def _is_supported_inline_button_url(url: str) -> bool: + try: + parsed = urlparse(url) + except ValueError: + return False + + if not parsed.scheme: + return False + + return parsed.scheme.lower() in {"http", "https", "tg", "ton", "ftp"} + + def get_happ_cryptolink_keyboard( subscription_link: str, language: str = DEFAULT_LANGUAGE, ) -> InlineKeyboardMarkup: texts = get_texts(language) - buttons = [ - [ + buttons = [] + + if _is_supported_inline_button_url(subscription_link): + buttons.append([ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link, ) - ], - [ + ]) + else: + buttons.append([ InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), - callback_data="happ_download_ios", + text=texts.t("HAPP_COPY_LINK_BUTTON", "📋 Получить ссылку"), + callback_data="happ_copy_link", ) - ], + ]) + + buttons.extend( [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), - callback_data="happ_download_android", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), - callback_data="happ_download_macos", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), - callback_data="happ_download_windows", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ], - ] + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), + callback_data="happ_download_ios", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), + callback_data="happ_download_android", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), + callback_data="happ_download_macos", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), + callback_data="happ_download_windows", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu", + ) + ], + ] + ) return InlineKeyboardMarkup(inline_keyboard=buttons) diff --git a/locales/en.json b/locales/en.json index 84a68c61..ca7bde2b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -26,6 +26,7 @@ "HAPP_PLATFORM_ANDROID": "🤖 Android", "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "HAPP_COPY_LINK_BUTTON": "📋 Get link", "HAPP_PLATFORM_PC": "💻 PC", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", @@ -408,7 +409,11 @@ "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Connect via Happ", "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Open link in Happ", + "SUBSCRIPTION_HAPP_OPEN_LINK_UNSUPPORTED": "🔓 Copy the link from the block below and open it in Happ manually.", "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually: {subscription_link}", + "SUBSCRIPTION_HAPP_OPEN_COPY_HINT": "📋 Use the button below to get the link in one tap.", + "SUBSCRIPTION_HAPP_COPY_MESSAGE": "🔗 Happ link:\n{subscription_link}", + "SUBSCRIPTION_HAPP_COPY_ALERT": "📋 The link was sent as a separate message.", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", diff --git a/locales/ru.json b/locales/ru.json index b1498e4e..b0eb4e59 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -189,6 +189,7 @@ "HAPP_PLATFORM_ANDROID": "🤖 Android", "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "HAPP_COPY_LINK_BUTTON": "📋 Получить ссылку", "HAPP_PLATFORM_PC": "💻 ПК", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", @@ -408,7 +409,11 @@ "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Подключение через Happ", "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Открыть ссылку в Happ", + "SUBSCRIPTION_HAPP_OPEN_LINK_UNSUPPORTED": "🔓 Скопируйте ссылку из блока ниже и откройте её в Happ вручную.", "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", + "SUBSCRIPTION_HAPP_OPEN_COPY_HINT": "📋 Нажмите кнопку ниже, чтобы получить ссылку одним нажатием.", + "SUBSCRIPTION_HAPP_COPY_MESSAGE": "🔗 Ссылка для Happ:\n{subscription_link}", + "SUBSCRIPTION_HAPP_COPY_ALERT": "📋 Ссылка отправлена отдельным сообщением.", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", From 4e6bc5ae719ab2e21b43f990ce28fc797598313b Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:39:34 +0300 Subject: [PATCH 099/146] Revert "Handle unsupported Happ cryptolink URLs in Telegram" --- app/handlers/subscription.py | 67 +++----------------------- app/keyboards/inline.py | 92 +++++++++++++----------------------- locales/en.json | 5 -- locales/ru.json | 5 -- 4 files changed, 39 insertions(+), 130 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index a98570e8..e6dab937 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -4589,45 +4589,11 @@ async def handle_no_traffic_packages( ): await callback.answer( "⚠️ В данный момент нет доступных пакетов трафика. " - "Обратитесь в техподдержку для получения информации.", + "Обратитесь в техподдержку для получения информации.", show_alert=True ) -async def handle_happ_copy_subscription_link( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - texts = get_texts(db_user.language) - subscription = db_user.subscription - subscription_link = get_display_subscription_link(subscription) - - if not subscription_link: - await callback.answer( - texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"), - show_alert=True, - ) - return - - await callback.message.answer( - texts.t( - "SUBSCRIPTION_HAPP_COPY_MESSAGE", - "🔗 Ссылка для Happ:\n{subscription_link}", - ).format(subscription_link=subscription_link), - parse_mode="HTML", - disable_web_page_preview=True, - ) - - await callback.answer( - texts.t( - "SUBSCRIPTION_HAPP_COPY_ALERT", - "📋 Ссылка отправлена отдельным сообщением.", - ), - show_alert=False, - ) - - async def handle_open_subscription_link( callback: types.CallbackQuery, db_user: User, @@ -4645,37 +4611,21 @@ async def handle_open_subscription_link( return if settings.is_happ_cryptolink_mode(): - allowed_schemes = ("http://", "https://", "tg://", "ton://", "ftp://") - link_supported = subscription_link.startswith(allowed_schemes) - - if link_supported: - link_line = texts.t( - "SUBSCRIPTION_HAPP_OPEN_LINK", - "🔓 Открыть ссылку в Happ", - ).format(subscription_link=subscription_link) - else: - link_line = texts.t( - "SUBSCRIPTION_HAPP_OPEN_LINK_UNSUPPORTED", - "🔓 Скопируйте ссылку из блока ниже и откройте её в Happ вручную.", - ) - happ_message = ( texts.t( "SUBSCRIPTION_HAPP_OPEN_TITLE", "🔗 Подключение через Happ", ) + "\n\n" - + link_line + + texts.t( + "SUBSCRIPTION_HAPP_OPEN_LINK", + "🔓 Открыть ссылку в Happ", + ).format(subscription_link=subscription_link) + "\n\n" + texts.t( "SUBSCRIPTION_HAPP_OPEN_HINT", "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", ).format(subscription_link=subscription_link) - + "\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_OPEN_COPY_HINT", - "📋 Нажмите кнопку ниже, чтобы получить ссылку одним нажатием.", - ) ) keyboard = get_happ_cryptolink_keyboard(subscription_link, db_user.language) @@ -5416,16 +5366,11 @@ def register_handlers(dp: Dispatcher): F.data == "happ_download_back" ) - dp.callback_query.register( - handle_happ_copy_subscription_link, - F.data == "happ_copy_link" - ) - dp.callback_query.register( handle_connect_subscription, F.data == "subscription_connect" ) - + dp.callback_query.register( handle_device_guide, F.data.startswith("device_guide_") diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 92b4345b..01e79ce4 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -2,7 +2,6 @@ from typing import List, Optional from aiogram import types from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from datetime import datetime -from urllib.parse import urlparse from app.database.models import User from sqlalchemy.ext.asyncio import AsyncSession @@ -255,74 +254,49 @@ def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]: ] -def _is_supported_inline_button_url(url: str) -> bool: - try: - parsed = urlparse(url) - except ValueError: - return False - - if not parsed.scheme: - return False - - return parsed.scheme.lower() in {"http", "https", "tg", "ton", "ftp"} - - def get_happ_cryptolink_keyboard( subscription_link: str, language: str = DEFAULT_LANGUAGE, ) -> InlineKeyboardMarkup: texts = get_texts(language) - buttons = [] - - if _is_supported_inline_button_url(subscription_link): - buttons.append([ + buttons = [ + [ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link, ) - ]) - else: - buttons.append([ - InlineKeyboardButton( - text=texts.t("HAPP_COPY_LINK_BUTTON", "📋 Получить ссылку"), - callback_data="happ_copy_link", - ) - ]) - - buttons.extend( + ], [ - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), - callback_data="happ_download_ios", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), - callback_data="happ_download_android", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), - callback_data="happ_download_macos", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), - callback_data="happ_download_windows", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ], - ] - ) + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), + callback_data="happ_download_ios", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), + callback_data="happ_download_android", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), + callback_data="happ_download_macos", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), + callback_data="happ_download_windows", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu", + ) + ], + ] return InlineKeyboardMarkup(inline_keyboard=buttons) diff --git a/locales/en.json b/locales/en.json index ca7bde2b..84a68c61 100644 --- a/locales/en.json +++ b/locales/en.json @@ -26,7 +26,6 @@ "HAPP_PLATFORM_ANDROID": "🤖 Android", "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", "HAPP_PLATFORM_WINDOWS": "💻 Windows", - "HAPP_COPY_LINK_BUTTON": "📋 Get link", "HAPP_PLATFORM_PC": "💻 PC", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", @@ -409,11 +408,7 @@ "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Connect via Happ", "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Open link in Happ", - "SUBSCRIPTION_HAPP_OPEN_LINK_UNSUPPORTED": "🔓 Copy the link from the block below and open it in Happ manually.", "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually: {subscription_link}", - "SUBSCRIPTION_HAPP_OPEN_COPY_HINT": "📋 Use the button below to get the link in one tap.", - "SUBSCRIPTION_HAPP_COPY_MESSAGE": "🔗 Happ link:\n{subscription_link}", - "SUBSCRIPTION_HAPP_COPY_ALERT": "📋 The link was sent as a separate message.", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", diff --git a/locales/ru.json b/locales/ru.json index b0eb4e59..b1498e4e 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -189,7 +189,6 @@ "HAPP_PLATFORM_ANDROID": "🤖 Android", "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", "HAPP_PLATFORM_WINDOWS": "💻 Windows", - "HAPP_COPY_LINK_BUTTON": "📋 Получить ссылку", "HAPP_PLATFORM_PC": "💻 ПК", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", @@ -409,11 +408,7 @@ "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Подключение через Happ", "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Открыть ссылку в Happ", - "SUBSCRIPTION_HAPP_OPEN_LINK_UNSUPPORTED": "🔓 Скопируйте ссылку из блока ниже и откройте её в Happ вручную.", "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", - "SUBSCRIPTION_HAPP_OPEN_COPY_HINT": "📋 Нажмите кнопку ниже, чтобы получить ссылку одним нажатием.", - "SUBSCRIPTION_HAPP_COPY_MESSAGE": "🔗 Ссылка для Happ:\n{subscription_link}", - "SUBSCRIPTION_HAPP_COPY_ALERT": "📋 Ссылка отправлена отдельным сообщением.", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", From 4bcefaa7b8ad44698e382efe3185d803be09507f Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:50:30 +0300 Subject: [PATCH 100/146] Add Happ redirect support for cryptolink mode --- .env.example | 4 ++++ README.md | 3 +++ app/config.py | 31 +++++++++++++++++++++++++ app/handlers/subscription.py | 16 ++++++++++++- app/keyboards/inline.py | 45 +++++++++++++++++++++++++----------- locales/en.json | 4 ++-- locales/ru.json | 4 ++-- 7 files changed, 89 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index 747f289c..cc6f5283 100644 --- a/.env.example +++ b/.env.example @@ -288,6 +288,10 @@ MINIAPP_CUSTOM_URL= # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false +# Включить кнопку "Подключиться" с редиректом на happ:// ссылку +CONNECT_BUTTON_HAPP_REDIRECT_ENABLED=false +# Шаблон редиректа, поддерживает плейсхолдер {subscription_link}. Если плейсхолдер не указан, ссылка будет добавлена в конец +CONNECT_BUTTON_HAPP_REDIRECT_TEMPLATE= HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_MACOS= diff --git a/README.md b/README.md index fb7f037d..c803586f 100644 --- a/README.md +++ b/README.md @@ -529,6 +529,9 @@ MINIAPP_CUSTOM_URL= # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false +CONNECT_BUTTON_HAPP_REDIRECT_ENABLED=false +# Шаблон редиректа, поддерживает плейсхолдер {subscription_link}. Если плейсхолдер не указан, ссылка добавится в конец +CONNECT_BUTTON_HAPP_REDIRECT_TEMPLATE= HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_MACOS= diff --git a/app/config.py b/app/config.py index 6d16054c..4a829f47 100644 --- a/app/config.py +++ b/app/config.py @@ -5,6 +5,8 @@ import html from collections import defaultdict from datetime import time from typing import List, Optional, Union, Dict +from urllib.parse import quote + from pydantic_settings import BaseSettings from pydantic import field_validator, Field from pathlib import Path @@ -210,6 +212,8 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False + CONNECT_BUTTON_HAPP_REDIRECT_ENABLED: bool = False + CONNECT_BUTTON_HAPP_REDIRECT_TEMPLATE: Optional[str] = None HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None @@ -573,6 +577,33 @@ class Settings(BaseSettings): link = links.get(platform_key) return link if link else None + def get_happ_redirect_url(self, subscription_link: Optional[str]) -> Optional[str]: + if not subscription_link: + return None + + if not self.is_happ_cryptolink_mode(): + return None + + if not self.CONNECT_BUTTON_HAPP_REDIRECT_ENABLED: + return None + + template = (self.CONNECT_BUTTON_HAPP_REDIRECT_TEMPLATE or "").strip() + if not template: + return None + + encoded_link = quote(subscription_link, safe="") + + if "{subscription_link}" in template: + try: + return template.format(subscription_link=encoded_link) + except Exception as exc: # pragma: no cover - safety fallback + logging.getLogger(__name__).warning( + "Failed to format Happ redirect template: %s", exc + ) + return None + + return f"{template}{encoded_link}" + def is_maintenance_mode(self) -> bool: return self.MAINTENANCE_MODE diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index e6dab937..cdfbea65 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -4611,12 +4611,22 @@ async def handle_open_subscription_link( return if settings.is_happ_cryptolink_mode(): + redirect_link = settings.get_happ_redirect_url(subscription_link) happ_message = ( texts.t( "SUBSCRIPTION_HAPP_OPEN_TITLE", "🔗 Подключение через Happ", ) + "\n\n" + + ( + texts.t( + "SUBSCRIPTION_HAPP_OPEN_REDIRECT_HINT", + "👇 Нажмите кнопку ниже, чтобы открыть Happ напрямую.", + ) + + "\n\n" + if redirect_link + else "" + ) + texts.t( "SUBSCRIPTION_HAPP_OPEN_LINK", "🔓 Открыть ссылку в Happ", @@ -4628,7 +4638,11 @@ async def handle_open_subscription_link( ).format(subscription_link=subscription_link) ) - keyboard = get_happ_cryptolink_keyboard(subscription_link, db_user.language) + keyboard = get_happ_cryptolink_keyboard( + subscription_link, + db_user.language, + redirect_link=redirect_link, + ) await callback.message.answer( happ_message, diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 01e79ce4..bb432f38 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -257,46 +257,65 @@ def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]: def get_happ_cryptolink_keyboard( subscription_link: str, language: str = DEFAULT_LANGUAGE, + redirect_link: Optional[str] = None, ) -> InlineKeyboardMarkup: texts = get_texts(language) - buttons = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription_link, - ) - ], + buttons: list[list[InlineKeyboardButton]] = [] + + if redirect_link: + buttons.append( + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=redirect_link, + ) + ] + ) + + buttons.extend( [ InlineKeyboardButton( text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), callback_data="happ_download_ios", ) - ], + ] + ) + + buttons.append( [ InlineKeyboardButton( text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), callback_data="happ_download_android", ) - ], + ] + ) + + buttons.append( [ InlineKeyboardButton( text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), callback_data="happ_download_macos", ) - ], + ] + ) + + buttons.append( [ InlineKeyboardButton( text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), callback_data="happ_download_windows", ) - ], + ] + ) + + buttons.append( [ InlineKeyboardButton( text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu", ) - ], - ] + ] + ) return InlineKeyboardMarkup(inline_keyboard=buttons) diff --git a/locales/en.json b/locales/en.json index 84a68c61..d1244dda 100644 --- a/locales/en.json +++ b/locales/en.json @@ -197,7 +197,6 @@ "NO_TICKETS_ADMIN": "No tickets to display.", "ADMIN_TICKETS_TITLE": "🎫 All support tickets:", "ADMIN_TICKET_REPLY_INPUT": "Enter support reply:", - "ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!", "TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.", "TICKET_UPDATE_ERROR": "❌ Error updating ticket.", @@ -519,5 +518,6 @@ "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):", "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):", "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):", - "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):" + "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):", + "SUBSCRIPTION_HAPP_OPEN_REDIRECT_HINT": "👇 Tap the button below to open Happ directly." } diff --git a/locales/ru.json b/locales/ru.json index b1498e4e..d2b71326 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -63,7 +63,6 @@ "NO_TICKETS_ADMIN": "Нет тикетов для отображения.", "ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:", "ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:", - "ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!", "TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.", "TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.", @@ -519,5 +518,6 @@ "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):", "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):", "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):", - "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):" + "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):", + "SUBSCRIPTION_HAPP_OPEN_REDIRECT_HINT": "👇 Нажмите кнопку ниже, чтобы открыть Happ напрямую." } From 67c6185f8bd7b73bfedc7459d52ddcc9afe9ad05 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:52:11 +0300 Subject: [PATCH 101/146] Revert "Add optional Happ redirect button for cryptolink mode" --- .env.example | 4 ---- README.md | 3 --- app/config.py | 31 ------------------------- app/handlers/subscription.py | 16 +------------ app/keyboards/inline.py | 45 +++++++++++------------------------- locales/en.json | 4 ++-- locales/ru.json | 4 ++-- 7 files changed, 18 insertions(+), 89 deletions(-) diff --git a/.env.example b/.env.example index cc6f5283..747f289c 100644 --- a/.env.example +++ b/.env.example @@ -288,10 +288,6 @@ MINIAPP_CUSTOM_URL= # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false -# Включить кнопку "Подключиться" с редиректом на happ:// ссылку -CONNECT_BUTTON_HAPP_REDIRECT_ENABLED=false -# Шаблон редиректа, поддерживает плейсхолдер {subscription_link}. Если плейсхолдер не указан, ссылка будет добавлена в конец -CONNECT_BUTTON_HAPP_REDIRECT_TEMPLATE= HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_MACOS= diff --git a/README.md b/README.md index c803586f..fb7f037d 100644 --- a/README.md +++ b/README.md @@ -529,9 +529,6 @@ MINIAPP_CUSTOM_URL= # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false -CONNECT_BUTTON_HAPP_REDIRECT_ENABLED=false -# Шаблон редиректа, поддерживает плейсхолдер {subscription_link}. Если плейсхолдер не указан, ссылка добавится в конец -CONNECT_BUTTON_HAPP_REDIRECT_TEMPLATE= HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_MACOS= diff --git a/app/config.py b/app/config.py index 4a829f47..6d16054c 100644 --- a/app/config.py +++ b/app/config.py @@ -5,8 +5,6 @@ import html from collections import defaultdict from datetime import time from typing import List, Optional, Union, Dict -from urllib.parse import quote - from pydantic_settings import BaseSettings from pydantic import field_validator, Field from pathlib import Path @@ -212,8 +210,6 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False - CONNECT_BUTTON_HAPP_REDIRECT_ENABLED: bool = False - CONNECT_BUTTON_HAPP_REDIRECT_TEMPLATE: Optional[str] = None HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None @@ -577,33 +573,6 @@ class Settings(BaseSettings): link = links.get(platform_key) return link if link else None - def get_happ_redirect_url(self, subscription_link: Optional[str]) -> Optional[str]: - if not subscription_link: - return None - - if not self.is_happ_cryptolink_mode(): - return None - - if not self.CONNECT_BUTTON_HAPP_REDIRECT_ENABLED: - return None - - template = (self.CONNECT_BUTTON_HAPP_REDIRECT_TEMPLATE or "").strip() - if not template: - return None - - encoded_link = quote(subscription_link, safe="") - - if "{subscription_link}" in template: - try: - return template.format(subscription_link=encoded_link) - except Exception as exc: # pragma: no cover - safety fallback - logging.getLogger(__name__).warning( - "Failed to format Happ redirect template: %s", exc - ) - return None - - return f"{template}{encoded_link}" - def is_maintenance_mode(self) -> bool: return self.MAINTENANCE_MODE diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index cdfbea65..e6dab937 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -4611,22 +4611,12 @@ async def handle_open_subscription_link( return if settings.is_happ_cryptolink_mode(): - redirect_link = settings.get_happ_redirect_url(subscription_link) happ_message = ( texts.t( "SUBSCRIPTION_HAPP_OPEN_TITLE", "🔗 Подключение через Happ", ) + "\n\n" - + ( - texts.t( - "SUBSCRIPTION_HAPP_OPEN_REDIRECT_HINT", - "👇 Нажмите кнопку ниже, чтобы открыть Happ напрямую.", - ) - + "\n\n" - if redirect_link - else "" - ) + texts.t( "SUBSCRIPTION_HAPP_OPEN_LINK", "🔓 Открыть ссылку в Happ", @@ -4638,11 +4628,7 @@ async def handle_open_subscription_link( ).format(subscription_link=subscription_link) ) - keyboard = get_happ_cryptolink_keyboard( - subscription_link, - db_user.language, - redirect_link=redirect_link, - ) + keyboard = get_happ_cryptolink_keyboard(subscription_link, db_user.language) await callback.message.answer( happ_message, diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index bb432f38..01e79ce4 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -257,65 +257,46 @@ def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]: def get_happ_cryptolink_keyboard( subscription_link: str, language: str = DEFAULT_LANGUAGE, - redirect_link: Optional[str] = None, ) -> InlineKeyboardMarkup: texts = get_texts(language) - buttons: list[list[InlineKeyboardButton]] = [] - - if redirect_link: - buttons.append( - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=redirect_link, - ) - ] - ) - - buttons.extend( + buttons = [ + [ + InlineKeyboardButton( + text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + url=subscription_link, + ) + ], [ InlineKeyboardButton( text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), callback_data="happ_download_ios", ) - ] - ) - - buttons.append( + ], [ InlineKeyboardButton( text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), callback_data="happ_download_android", ) - ] - ) - - buttons.append( + ], [ InlineKeyboardButton( text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), callback_data="happ_download_macos", ) - ] - ) - - buttons.append( + ], [ InlineKeyboardButton( text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), callback_data="happ_download_windows", ) - ] - ) - - buttons.append( + ], [ InlineKeyboardButton( text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu", ) - ] - ) + ], + ] return InlineKeyboardMarkup(inline_keyboard=buttons) diff --git a/locales/en.json b/locales/en.json index d1244dda..84a68c61 100644 --- a/locales/en.json +++ b/locales/en.json @@ -197,6 +197,7 @@ "NO_TICKETS_ADMIN": "No tickets to display.", "ADMIN_TICKETS_TITLE": "🎫 All support tickets:", "ADMIN_TICKET_REPLY_INPUT": "Enter support reply:", + "ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!", "TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.", "TICKET_UPDATE_ERROR": "❌ Error updating ticket.", @@ -518,6 +519,5 @@ "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):", "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):", "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):", - "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):", - "SUBSCRIPTION_HAPP_OPEN_REDIRECT_HINT": "👇 Tap the button below to open Happ directly." + "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):" } diff --git a/locales/ru.json b/locales/ru.json index d2b71326..b1498e4e 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -63,6 +63,7 @@ "NO_TICKETS_ADMIN": "Нет тикетов для отображения.", "ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:", "ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:", + "ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!", "TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.", "TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.", @@ -518,6 +519,5 @@ "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):", "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):", "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):", - "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):", - "SUBSCRIPTION_HAPP_OPEN_REDIRECT_HINT": "👇 Нажмите кнопку ниже, чтобы открыть Happ напрямую." + "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):" } From 57db7532189de7a90b68fdc080dc748d313bc270 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:52:47 +0300 Subject: [PATCH 102/146] Support Happ redirect button for cryptolink mode --- app/config.py | 5 +++++ app/handlers/subscription.py | 18 ++++++++++++++++-- app/keyboards/inline.py | 22 ++++++++++++++++------ app/utils/subscription_utils.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/app/config.py b/app/config.py index 6d16054c..9de0a3a0 100644 --- a/app/config.py +++ b/app/config.py @@ -210,6 +210,7 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False + HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None @@ -555,6 +556,10 @@ class Settings(BaseSettings): def is_happ_download_button_enabled(self) -> bool: return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED + def get_happ_cryptolink_redirect_template(self) -> Optional[str]: + template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip() + return template or None + def get_happ_download_link(self, platform: str) -> Optional[str]: platform_key = platform.lower() diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index e6dab937..97b91d0b 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -64,7 +64,10 @@ from app.utils.pricing_utils import ( format_period_description, ) from app.utils.pagination import paginate_list -from app.utils.subscription_utils import get_display_subscription_link +from app.utils.subscription_utils import ( + get_display_subscription_link, + get_happ_cryptolink_redirect_link, +) logger = logging.getLogger(__name__) @@ -4611,6 +4614,7 @@ async def handle_open_subscription_link( return if settings.is_happ_cryptolink_mode(): + redirect_link = get_happ_cryptolink_redirect_link(subscription_link) happ_message = ( texts.t( "SUBSCRIPTION_HAPP_OPEN_TITLE", @@ -4628,7 +4632,17 @@ async def handle_open_subscription_link( ).format(subscription_link=subscription_link) ) - keyboard = get_happ_cryptolink_keyboard(subscription_link, db_user.language) + if redirect_link: + happ_message += "\n\n" + texts.t( + "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT", + "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.", + ) + + keyboard = get_happ_cryptolink_keyboard( + subscription_link, + db_user.language, + redirect_link=redirect_link, + ) await callback.message.answer( happ_message, diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 01e79ce4..adb4462f 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -9,7 +9,10 @@ from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts from app.utils.pricing_utils import format_period_description -from app.utils.subscription_utils import get_display_subscription_link +from app.utils.subscription_utils import ( + get_display_subscription_link, + get_happ_cryptolink_redirect_link, +) import logging logger = logging.getLogger(__name__) @@ -257,15 +260,22 @@ def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]: def get_happ_cryptolink_keyboard( subscription_link: str, language: str = DEFAULT_LANGUAGE, + redirect_link: Optional[str] = None, ) -> InlineKeyboardMarkup: texts = get_texts(language) - buttons = [ - [ + final_redirect_link = redirect_link or get_happ_cryptolink_redirect_link(subscription_link) + + buttons: List[List[InlineKeyboardButton]] = [] + + if final_redirect_link: + buttons.append([ InlineKeyboardButton( text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription_link, + url=final_redirect_link, ) - ], + ]) + + buttons.extend([ [ InlineKeyboardButton( text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), @@ -296,7 +306,7 @@ def get_happ_cryptolink_keyboard( callback_data="back_to_menu", ) ], - ] + ]) return InlineKeyboardMarkup(inline_keyboard=buttons) diff --git a/app/utils/subscription_utils.py b/app/utils/subscription_utils.py index 62db4142..a2741b5d 100644 --- a/app/utils/subscription_utils.py +++ b/app/utils/subscription_utils.py @@ -1,6 +1,7 @@ import logging from datetime import datetime from typing import Optional +from urllib.parse import quote from sqlalchemy import select, delete, func from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import Subscription, User @@ -109,3 +110,34 @@ def get_display_subscription_link(subscription: Optional[Subscription]) -> Optio return crypto_link or base_link return base_link + + +def get_happ_cryptolink_redirect_link(subscription_link: Optional[str]) -> Optional[str]: + if not subscription_link: + return None + + template = settings.get_happ_cryptolink_redirect_template() + if not template: + return None + + encoded_link = quote(subscription_link, safe="") + replacements = { + "{subscription_link}": encoded_link, + "{link}": encoded_link, + "{subscription_link_raw}": subscription_link, + "{link_raw}": subscription_link, + } + + replaced = False + for placeholder, value in replacements.items(): + if placeholder in template: + template = template.replace(placeholder, value) + replaced = True + + if replaced: + return template + + if template.endswith(("=", "?", "&")): + return f"{template}{encoded_link}" + + return f"{template}{encoded_link}" From 4f4c9e5426afd0683cd3d2dc610246d13e49744e Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:59:11 +0300 Subject: [PATCH 103/146] Update .env.example --- .env.example | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 747f289c..f2647f8b 100644 --- a/.env.example +++ b/.env.example @@ -280,7 +280,7 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) -# happ_cryptolink - открывает ссылку из поля cryptoLink (режим 5) +# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) @@ -292,8 +292,8 @@ HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_MACOS= HAPP_DOWNLOAD_LINK_WINDOWS= -# (опционально) устаревшее поле для Windows, будет использовано если HAPP_DOWNLOAD_LINK_WINDOWS не задан -HAPP_DOWNLOAD_LINK_PC= +# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to= +HAPP_CRYPTOLINK_REDIRECT_TEMPLATE= # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false From 59cae2fde68c2a0aff2490f712422defad7bf812 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 11:59:48 +0300 Subject: [PATCH 104/146] Update README.md --- README.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index fb7f037d..748e2e94 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,11 @@ ADMIN_NOTIFICATIONS_ENABLED=true ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ! ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов +# Автоматические отчеты +ADMIN_REPORTS_ENABLED=false +ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID) +ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов +ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета # Обязательная подписка на канал CHANNEL_SUB_ID= # Опционально ID твоего канала (-100) CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал @@ -501,12 +506,6 @@ PAL24_MIN_AMOUNT_KOPEKS=10000 PAL24_MAX_AMOUNT_KOPEKS=100000000 PAL24_REQUEST_TIMEOUT=30 -# Настройки PayPalych -1. Включите интеграцию (`PAL24_ENABLED=true`) и укажите `PAL24_API_TOKEN`, `PAL24_SHOP_ID`, а также `PAL24_SIGNATURE_TOKEN` для проверки подписи уведомлений. -2. Настройте в кабинете PayPalych **Result URL** и success/fail redirect на `https://<ваш-домен>/pal24-webhook`. -3. Убедитесь, что порт `PAL24_WEBHOOK_PORT` (по умолчанию `8084`) проброшен через прокси/фаервол. -4. Для теста можно отправить postback вручную (пример команды см. ниже в разделе «Проверка PayPalych postback»). - # ===== ИНТЕРФЕЙС И UX ===== # Включить логотип для всех сообщений (true - с изображением, false - только текст) @@ -521,7 +520,7 @@ HIDE_SUBSCRIPTION_LINK=false # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) # link - Открывает ссылку напрямую в браузере (режим 4) -# happ_cryptolink - открывает ссылку из поля cryptoLink (режим 5) +# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) @@ -533,8 +532,8 @@ HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_MACOS= HAPP_DOWNLOAD_LINK_WINDOWS= -# (опционально) устаревшее поле для Windows, будет использовано если HAPP_DOWNLOAD_LINK_WINDOWS не задан -HAPP_DOWNLOAD_LINK_PC= +# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to= +HAPP_CRYPTOLINK_REDIRECT_TEMPLATE= # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false From 43d75dc3ec789af055534a3ad51353c6f925a6c5 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 12:09:04 +0300 Subject: [PATCH 105/146] Add missing Happ subscription localization keys --- locales/en.json | 2 ++ locales/ru.json | 2 ++ 2 files changed, 4 insertions(+) diff --git a/locales/en.json b/locales/en.json index 84a68c61..f217ed28 100644 --- a/locales/en.json +++ b/locales/en.json @@ -395,6 +395,7 @@ "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app", "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Your import link for the VPN app:\n{subscription_url}", "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device", + "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.", "BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu", "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured", "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.", @@ -409,6 +410,7 @@ "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Connect via Happ", "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Open link in Happ", "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually: {subscription_link}", + "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", diff --git a/locales/ru.json b/locales/ru.json index b1498e4e..2524c1d4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -395,6 +395,7 @@ "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню", "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена", "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", @@ -409,6 +410,7 @@ "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Подключение через Happ", "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Открыть ссылку в Happ", "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", + "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.", "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", From 9c572a277130e0cdb09f421a2b64fd3e29e14e73 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 12:43:13 +0300 Subject: [PATCH 106/146] Add promo group option for add-on discounts --- app/database/crud/promo_group.py | 9 +- app/database/crud/subscription.py | 10 ++ app/database/crud/user.py | 1 + app/database/models.py | 1 + app/database/universal_migration.py | 44 +++++++++ app/handlers/admin/promo_groups.py | 141 ++++++++++++++++++++++++++++ app/handlers/admin/users.py | 11 ++- app/states.py | 2 + locales/en.json | 12 +++ locales/ru.json | 12 +++ 10 files changed, 241 insertions(+), 2 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 3bc093f2..eaa6fabb 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -60,6 +60,7 @@ async def create_promo_group( device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + apply_addon_discounts: bool = True, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) @@ -76,6 +77,7 @@ async def create_promo_group( device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, + apply_addon_discounts=bool(apply_addon_discounts), is_default=False, ) @@ -84,13 +86,15 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s)," + " порогом автоприсвоения %s₽ и применением скидок на доп. услуги: %s", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, normalized_period_discounts, (auto_assign_total_spent_kopeks or 0) / 100, + promo_group.apply_addon_discounts, ) return promo_group @@ -106,6 +110,7 @@ async def update_promo_group( device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + apply_addon_discounts: Optional[bool] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -120,6 +125,8 @@ async def update_promo_group( group.period_discounts = normalized_period_discounts or None if auto_assign_total_spent_kopeks is not None: group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) + if apply_addon_discounts is not None: + group.apply_addon_discounts = bool(apply_addon_discounts) await db.commit() await db.refresh(group) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 91b79375..d4fc1642 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -845,6 +845,10 @@ async def calculate_addon_cost_for_remaining_period( user = getattr(subscription, "user", None) promo_group = promo_group or (user.promo_group if user else None) + addon_discounts_enabled = True + if promo_group is not None: + addon_discounts_enabled = getattr(promo_group, "apply_addon_discounts", True) + if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) traffic_discount_percent = _get_discount_percent( @@ -853,6 +857,8 @@ async def calculate_addon_cost_for_remaining_period( "traffic", period_days=period_hint_days, ) + if not addon_discounts_enabled: + traffic_discount_percent = 0 traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month traffic_total_cost = discounted_traffic_per_month * months_to_pay @@ -874,6 +880,8 @@ async def calculate_addon_cost_for_remaining_period( "devices", period_days=period_hint_days, ) + if not addon_discounts_enabled: + devices_discount_percent = 0 devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month devices_total_cost = discounted_devices_per_month * months_to_pay @@ -903,6 +911,8 @@ async def calculate_addon_cost_for_remaining_period( "servers", period_days=period_hint_days, ) + if not addon_discounts_enabled: + servers_discount_percent = 0 server_discount_per_month = server_price_per_month * servers_discount_percent // 100 discounted_server_per_month = server_price_per_month - server_discount_per_month server_total_cost = discounted_server_per_month * months_to_pay diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 582c8695..bf948d18 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -97,6 +97,7 @@ async def create_user( server_discount_percent=0, traffic_discount_percent=0, device_discount_percent=0, + apply_addon_discounts=True, is_default=True, ) db.add(default_group) diff --git a/app/database/models.py b/app/database/models.py index 0a3ad865..1db78205 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -292,6 +292,7 @@ class PromoGroup(Base): device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) + apply_addon_discounts = Column(Boolean, nullable=False, default=True) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index b123c750..5042a046 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -790,6 +790,7 @@ async def ensure_promo_groups_setup(): server_discount_percent INTEGER NOT NULL DEFAULT 0, traffic_discount_percent INTEGER NOT NULL DEFAULT 0, device_discount_percent INTEGER NOT NULL DEFAULT 0, + apply_addon_discounts BOOLEAN NOT NULL DEFAULT 1, is_default BOOLEAN NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP @@ -812,6 +813,7 @@ async def ensure_promo_groups_setup(): server_discount_percent INTEGER NOT NULL DEFAULT 0, traffic_discount_percent INTEGER NOT NULL DEFAULT 0, device_discount_percent INTEGER NOT NULL DEFAULT 0, + apply_addon_discounts BOOLEAN NOT NULL DEFAULT TRUE, is_default BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, @@ -830,6 +832,7 @@ async def ensure_promo_groups_setup(): server_discount_percent INT NOT NULL DEFAULT 0, traffic_discount_percent INT NOT NULL DEFAULT 0, device_discount_percent INT NOT NULL DEFAULT 0, + apply_addon_discounts TINYINT(1) NOT NULL DEFAULT 1, is_default TINYINT(1) NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -931,6 +934,44 @@ async def ensure_promo_groups_setup(): "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" ) + addon_discount_column_exists = await check_column_exists( + "promo_groups", "apply_addon_discounts" + ) + + if not addon_discount_column_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_addon_discounts BOOLEAN NOT NULL DEFAULT 1" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_addon_discounts BOOLEAN NOT NULL DEFAULT TRUE" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_addon_discounts TINYINT(1) NOT NULL DEFAULT 1" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.apply_addon_discounts: {db_type}" + ) + return False + + await conn.execute( + text( + "UPDATE promo_groups SET apply_addon_discounts = 1 WHERE apply_addon_discounts IS NULL" + ) + ) + logger.info( + "Добавлена колонка promo_groups.apply_addon_discounts" + ) + column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -1994,6 +2035,7 @@ async def check_migration_status(): "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, + "promo_groups_addon_discount_column": False, "users_auto_promo_group_assigned_column": False, "subscription_crypto_link_column": False, } @@ -2011,6 +2053,7 @@ async def check_migration_status(): status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') + status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_addon_discounts') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') @@ -2048,6 +2091,7 @@ async def check_migration_status(): "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", + "promo_groups_addon_discount_column": "Колонка apply_addon_discounts у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 917f673f..1b7ddb49 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -39,6 +39,21 @@ def _format_discount_line(texts, group) -> str: ) +def _format_addon_discount_line(texts, group: PromoGroup) -> str: + enabled = getattr(group, "apply_addon_discounts", True) + key = ( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED" + if enabled + else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED" + ) + default = ( + "Скидки на доп. услуги: применяются" + if enabled + else "Скидки на доп. услуги: не применяются" + ) + return texts.t(key, default) + + def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: if not raw or not isinstance(raw, dict): return {} @@ -174,6 +189,16 @@ def _format_rubles(amount_kopeks: int) -> str: return formatted.replace(",", " ") +def _format_addon_discount_state(texts, enabled: bool) -> str: + key = ( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATE_ENABLED" + if enabled + else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATE_DISABLED" + ) + default = "включены" if enabled else "выключены" + return texts.t(key, default) + + def _format_auto_assign_line(texts, group: PromoGroup) -> str: threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0 @@ -223,6 +248,17 @@ def _parse_auto_assign_threshold_input(value: str) -> int: return max(0, kopeks) +def _parse_boolean_choice(value: str) -> bool: + cleaned = (value or "").strip().lower() + + if cleaned in {"1", "true", "yes", "y", "да", "+", "on", "вкл"}: + return True + if cleaned in {"0", "false", "no", "n", "нет", "-", "off", "выкл"}: + return False + + raise ValueError + + async def _prompt_for_auto_assign_threshold( message: types.Message, state: FSMContext, @@ -257,6 +293,7 @@ def _build_edit_menu_content( lines = [ header, _format_discount_line(texts, group), + _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), ] @@ -318,6 +355,15 @@ def _build_edit_menu_content( callback_data=f"promo_group_edit_field_{group.id}_periods", ) ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDONS", + "🎁 Скидки на доп. услуги", + ), + callback_data=f"promo_group_edit_field_{group.id}_addons", + ) + ], [ types.InlineKeyboardButton( text=texts.t( @@ -399,6 +445,7 @@ async def show_promo_groups_menu( group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), + _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", @@ -474,6 +521,7 @@ async def show_promo_group_details( "💳 Промогруппа: {name}", ).format(name=group.name), _format_discount_line(texts, group), + _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", @@ -675,6 +723,39 @@ async def process_create_group_period_discounts( return await state.update_data(new_group_period_discounts=period_discounts) + await state.set_state(AdminStates.creating_promo_group_addon_discount) + + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_CREATE_ADDONS_PROMPT", + "Включать скидки на докупку доп. услуг? (да/нет)", + ) + ) + + +@admin_required +@error_handler +async def process_create_group_addon_discounts( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + apply_addons = _parse_boolean_choice(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT", + "Введите «да» или «нет».", + ) + ) + return + + await state.update_data(new_group_apply_addon_discounts=apply_addons) await state.set_state(AdminStates.creating_promo_group_auto_assign) await _prompt_for_auto_assign_threshold( @@ -716,6 +797,7 @@ async def process_create_group_auto_assign( device_discount_percent=data["new_group_devices"], period_discounts=data.get("new_group_period_discounts"), auto_assign_total_spent_kopeks=auto_assign_kopeks, + apply_addon_discounts=data.get("new_group_apply_addon_discounts", True), ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -826,6 +908,17 @@ async def prompt_edit_promo_group_field( "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT", "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", ).format(current=_format_period_discounts_value(current_discounts)) + elif field == "addons": + await state.set_state(AdminStates.editing_promo_group_addon_discount) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_ADDONS_PROMPT", + "Включать скидки на докупку доп. услуг? Текущее значение: {current}. Введите да/нет.", + ).format( + current=_format_addon_discount_state( + texts, + getattr(group, "apply_addon_discounts", True), + ) + ) elif field == "auto": await state.set_state(AdminStates.editing_promo_group_auto_assign) prompt = texts.t( @@ -1019,6 +1112,46 @@ async def process_edit_group_period_discounts( ) +@admin_required +@error_handler +async def process_edit_group_addon_discounts( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + apply_addons = _parse_boolean_choice(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT", + "Введите «да» или «нет».", + ) + ) + return + + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group(db, group, apply_addon_discounts=apply_addons) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( + message, + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), + ) + + @admin_required @error_handler async def process_edit_group_auto_assign( @@ -1235,6 +1368,10 @@ def register_handlers(dp: Dispatcher): process_create_group_period_discounts, AdminStates.creating_promo_group_period_discount, ) + dp.message.register( + process_create_group_addon_discounts, + AdminStates.creating_promo_group_addon_discount, + ) dp.message.register( process_create_group_auto_assign, AdminStates.creating_promo_group_auto_assign, @@ -1257,6 +1394,10 @@ def register_handlers(dp: Dispatcher): process_edit_group_period_discounts, AdminStates.editing_promo_group_period_discount, ) + dp.message.register( + process_edit_group_addon_discounts, + AdminStates.editing_promo_group_addon_discount, + ) dp.message.register( process_edit_group_auto_assign, AdminStates.editing_promo_group_auto_assign, diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 45fe983f..69b2b5ea 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -868,16 +868,25 @@ async def _render_user_promo_group( traffic=current_group.traffic_discount_percent, devices=current_group.device_discount_percent, ) + addon_line = texts.ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT.format( + state=( + texts.ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_ENABLED + if getattr(current_group, "apply_addon_discounts", True) + else texts.ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_DISABLED + ) + ) current_group_id = current_group.id else: current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT_NONE discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE + addon_line = texts.ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE current_group_id = None text = ( f"{texts.ADMIN_USER_PROMO_GROUP_TITLE}\n\n" f"{current_line}\n" - f"{discount_line}\n\n" + f"{discount_line}\n" + f"{addon_line}\n\n" f"{texts.ADMIN_USER_PROMO_GROUP_SELECT}" ) diff --git a/app/states.py b/app/states.py index f824f9a5..6bf45cf3 100644 --- a/app/states.py +++ b/app/states.py @@ -69,6 +69,7 @@ class AdminStates(StatesGroup): creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() creating_promo_group_period_discount = State() + creating_promo_group_addon_discount = State() creating_promo_group_auto_assign = State() editing_promo_group_menu = State() @@ -77,6 +78,7 @@ class AdminStates(StatesGroup): editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() editing_promo_group_period_discount = State() + editing_promo_group_addon_discount = State() editing_promo_group_auto_assign = State() editing_squad_price = State() diff --git a/locales/en.json b/locales/en.json index f217ed28..df0e9343 100644 --- a/locales/en.json +++ b/locales/en.json @@ -151,6 +151,8 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🎁 Add-on discounts: applied", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🎁 Add-on discounts: not applied", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", @@ -227,6 +229,10 @@ "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned", "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT": "Add-on discounts: {state}", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "applied", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "not applied", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE": "Add-on discounts: no data", "ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:", "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”", "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.", @@ -244,8 +250,10 @@ "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", + "ADMIN_PROMO_GROUP_CREATE_ADDONS_PROMPT": "Apply discounts to add-on purchases? (yes/no)", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", + "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Please enter “yes” or “no”.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", @@ -253,9 +261,12 @@ "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100). Current value: {current}.", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100). Current value: {current}.", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", + "ADMIN_PROMO_GROUP_EDIT_ADDONS_PROMPT": "Apply discounts to add-on purchases? Current value: {current}. Enter yes/no.", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATE_ENABLED": "enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATE_DISABLED": "disabled", "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”", "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:", "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename", @@ -263,6 +274,7 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDONS": "🎁 Add-on discounts", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.", diff --git a/locales/ru.json b/locales/ru.json index 2524c1d4..c50d95a3 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -17,6 +17,8 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🎁 Скидки на доп. услуги: применяются", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🎁 Скидки на доп. услуги: не применяются", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", @@ -93,6 +95,10 @@ "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена", "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT": "Скидки на доп. услуги: {state}", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "применяются", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "не применяются", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE": "Скидки на доп. услуги: нет данных", "ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:", "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»", "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.", @@ -110,8 +116,10 @@ "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", + "ADMIN_PROMO_GROUP_CREATE_ADDONS_PROMPT": "Включать скидки на докупку доп. услуг? (да/нет)", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", + "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Введите «да» или «нет».", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", @@ -119,9 +127,12 @@ "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100). Текущее значение: {current}.", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100). Текущее значение: {current}.", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", + "ADMIN_PROMO_GROUP_EDIT_ADDONS_PROMPT": "Включать скидки на докупку доп. услуг? Текущее значение: {current}. Введите да/нет.", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATE_ENABLED": "включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATE_DISABLED": "выключены", "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»", "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:", "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название", @@ -129,6 +140,7 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDONS": "🎁 Скидки на доп. услуги", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.", From 25eff6929bcd9d5e4887c0d8dab34d85ee82f65a Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 12:45:24 +0300 Subject: [PATCH 107/146] Revert "Add promo group toggle for add-on discounts" --- app/database/crud/promo_group.py | 9 +- app/database/crud/subscription.py | 10 -- app/database/crud/user.py | 1 - app/database/models.py | 1 - app/database/universal_migration.py | 44 --------- app/handlers/admin/promo_groups.py | 141 ---------------------------- app/handlers/admin/users.py | 11 +-- app/states.py | 2 - locales/en.json | 12 --- locales/ru.json | 12 --- 10 files changed, 2 insertions(+), 241 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index eaa6fabb..3bc093f2 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -60,7 +60,6 @@ async def create_promo_group( device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, - apply_addon_discounts: bool = True, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) @@ -77,7 +76,6 @@ async def create_promo_group( device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, - apply_addon_discounts=bool(apply_addon_discounts), is_default=False, ) @@ -86,15 +84,13 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s)," - " порогом автоприсвоения %s₽ и применением скидок на доп. услуги: %s", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, normalized_period_discounts, (auto_assign_total_spent_kopeks or 0) / 100, - promo_group.apply_addon_discounts, ) return promo_group @@ -110,7 +106,6 @@ async def update_promo_group( device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, - apply_addon_discounts: Optional[bool] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -125,8 +120,6 @@ async def update_promo_group( group.period_discounts = normalized_period_discounts or None if auto_assign_total_spent_kopeks is not None: group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) - if apply_addon_discounts is not None: - group.apply_addon_discounts = bool(apply_addon_discounts) await db.commit() await db.refresh(group) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index d4fc1642..91b79375 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -845,10 +845,6 @@ async def calculate_addon_cost_for_remaining_period( user = getattr(subscription, "user", None) promo_group = promo_group or (user.promo_group if user else None) - addon_discounts_enabled = True - if promo_group is not None: - addon_discounts_enabled = getattr(promo_group, "apply_addon_discounts", True) - if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) traffic_discount_percent = _get_discount_percent( @@ -857,8 +853,6 @@ async def calculate_addon_cost_for_remaining_period( "traffic", period_days=period_hint_days, ) - if not addon_discounts_enabled: - traffic_discount_percent = 0 traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month traffic_total_cost = discounted_traffic_per_month * months_to_pay @@ -880,8 +874,6 @@ async def calculate_addon_cost_for_remaining_period( "devices", period_days=period_hint_days, ) - if not addon_discounts_enabled: - devices_discount_percent = 0 devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month devices_total_cost = discounted_devices_per_month * months_to_pay @@ -911,8 +903,6 @@ async def calculate_addon_cost_for_remaining_period( "servers", period_days=period_hint_days, ) - if not addon_discounts_enabled: - servers_discount_percent = 0 server_discount_per_month = server_price_per_month * servers_discount_percent // 100 discounted_server_per_month = server_price_per_month - server_discount_per_month server_total_cost = discounted_server_per_month * months_to_pay diff --git a/app/database/crud/user.py b/app/database/crud/user.py index bf948d18..582c8695 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -97,7 +97,6 @@ async def create_user( server_discount_percent=0, traffic_discount_percent=0, device_discount_percent=0, - apply_addon_discounts=True, is_default=True, ) db.add(default_group) diff --git a/app/database/models.py b/app/database/models.py index 1db78205..0a3ad865 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -292,7 +292,6 @@ class PromoGroup(Base): device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) - apply_addon_discounts = Column(Boolean, nullable=False, default=True) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 5042a046..b123c750 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -790,7 +790,6 @@ async def ensure_promo_groups_setup(): server_discount_percent INTEGER NOT NULL DEFAULT 0, traffic_discount_percent INTEGER NOT NULL DEFAULT 0, device_discount_percent INTEGER NOT NULL DEFAULT 0, - apply_addon_discounts BOOLEAN NOT NULL DEFAULT 1, is_default BOOLEAN NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP @@ -813,7 +812,6 @@ async def ensure_promo_groups_setup(): server_discount_percent INTEGER NOT NULL DEFAULT 0, traffic_discount_percent INTEGER NOT NULL DEFAULT 0, device_discount_percent INTEGER NOT NULL DEFAULT 0, - apply_addon_discounts BOOLEAN NOT NULL DEFAULT TRUE, is_default BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, @@ -832,7 +830,6 @@ async def ensure_promo_groups_setup(): server_discount_percent INT NOT NULL DEFAULT 0, traffic_discount_percent INT NOT NULL DEFAULT 0, device_discount_percent INT NOT NULL DEFAULT 0, - apply_addon_discounts TINYINT(1) NOT NULL DEFAULT 1, is_default TINYINT(1) NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -934,44 +931,6 @@ async def ensure_promo_groups_setup(): "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" ) - addon_discount_column_exists = await check_column_exists( - "promo_groups", "apply_addon_discounts" - ) - - if not addon_discount_column_exists: - if db_type == "sqlite": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN apply_addon_discounts BOOLEAN NOT NULL DEFAULT 1" - ) - ) - elif db_type == "postgresql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN apply_addon_discounts BOOLEAN NOT NULL DEFAULT TRUE" - ) - ) - elif db_type == "mysql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN apply_addon_discounts TINYINT(1) NOT NULL DEFAULT 1" - ) - ) - else: - logger.error( - f"Неподдерживаемый тип БД для promo_groups.apply_addon_discounts: {db_type}" - ) - return False - - await conn.execute( - text( - "UPDATE promo_groups SET apply_addon_discounts = 1 WHERE apply_addon_discounts IS NULL" - ) - ) - logger.info( - "Добавлена колонка promo_groups.apply_addon_discounts" - ) - column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -2035,7 +1994,6 @@ async def check_migration_status(): "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, - "promo_groups_addon_discount_column": False, "users_auto_promo_group_assigned_column": False, "subscription_crypto_link_column": False, } @@ -2053,7 +2011,6 @@ async def check_migration_status(): status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') - status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_addon_discounts') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') @@ -2091,7 +2048,6 @@ async def check_migration_status(): "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", - "promo_groups_addon_discount_column": "Колонка apply_addon_discounts у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 1b7ddb49..917f673f 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -39,21 +39,6 @@ def _format_discount_line(texts, group) -> str: ) -def _format_addon_discount_line(texts, group: PromoGroup) -> str: - enabled = getattr(group, "apply_addon_discounts", True) - key = ( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED" - if enabled - else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED" - ) - default = ( - "Скидки на доп. услуги: применяются" - if enabled - else "Скидки на доп. услуги: не применяются" - ) - return texts.t(key, default) - - def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: if not raw or not isinstance(raw, dict): return {} @@ -189,16 +174,6 @@ def _format_rubles(amount_kopeks: int) -> str: return formatted.replace(",", " ") -def _format_addon_discount_state(texts, enabled: bool) -> str: - key = ( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATE_ENABLED" - if enabled - else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATE_DISABLED" - ) - default = "включены" if enabled else "выключены" - return texts.t(key, default) - - def _format_auto_assign_line(texts, group: PromoGroup) -> str: threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0 @@ -248,17 +223,6 @@ def _parse_auto_assign_threshold_input(value: str) -> int: return max(0, kopeks) -def _parse_boolean_choice(value: str) -> bool: - cleaned = (value or "").strip().lower() - - if cleaned in {"1", "true", "yes", "y", "да", "+", "on", "вкл"}: - return True - if cleaned in {"0", "false", "no", "n", "нет", "-", "off", "выкл"}: - return False - - raise ValueError - - async def _prompt_for_auto_assign_threshold( message: types.Message, state: FSMContext, @@ -293,7 +257,6 @@ def _build_edit_menu_content( lines = [ header, _format_discount_line(texts, group), - _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), ] @@ -355,15 +318,6 @@ def _build_edit_menu_content( callback_data=f"promo_group_edit_field_{group.id}_periods", ) ], - [ - types.InlineKeyboardButton( - text=texts.t( - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDONS", - "🎁 Скидки на доп. услуги", - ), - callback_data=f"promo_group_edit_field_{group.id}_addons", - ) - ], [ types.InlineKeyboardButton( text=texts.t( @@ -445,7 +399,6 @@ async def show_promo_groups_menu( group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), - _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", @@ -521,7 +474,6 @@ async def show_promo_group_details( "💳 Промогруппа: {name}", ).format(name=group.name), _format_discount_line(texts, group), - _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", @@ -723,39 +675,6 @@ async def process_create_group_period_discounts( return await state.update_data(new_group_period_discounts=period_discounts) - await state.set_state(AdminStates.creating_promo_group_addon_discount) - - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_CREATE_ADDONS_PROMPT", - "Включать скидки на докупку доп. услуг? (да/нет)", - ) - ) - - -@admin_required -@error_handler -async def process_create_group_addon_discounts( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - apply_addons = _parse_boolean_choice(message.text) - except ValueError: - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT", - "Введите «да» или «нет».", - ) - ) - return - - await state.update_data(new_group_apply_addon_discounts=apply_addons) await state.set_state(AdminStates.creating_promo_group_auto_assign) await _prompt_for_auto_assign_threshold( @@ -797,7 +716,6 @@ async def process_create_group_auto_assign( device_discount_percent=data["new_group_devices"], period_discounts=data.get("new_group_period_discounts"), auto_assign_total_spent_kopeks=auto_assign_kopeks, - apply_addon_discounts=data.get("new_group_apply_addon_discounts", True), ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -908,17 +826,6 @@ async def prompt_edit_promo_group_field( "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT", "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", ).format(current=_format_period_discounts_value(current_discounts)) - elif field == "addons": - await state.set_state(AdminStates.editing_promo_group_addon_discount) - prompt = texts.t( - "ADMIN_PROMO_GROUP_EDIT_ADDONS_PROMPT", - "Включать скидки на докупку доп. услуг? Текущее значение: {current}. Введите да/нет.", - ).format( - current=_format_addon_discount_state( - texts, - getattr(group, "apply_addon_discounts", True), - ) - ) elif field == "auto": await state.set_state(AdminStates.editing_promo_group_auto_assign) prompt = texts.t( @@ -1112,46 +1019,6 @@ async def process_edit_group_period_discounts( ) -@admin_required -@error_handler -async def process_edit_group_addon_discounts( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - apply_addons = _parse_boolean_choice(message.text) - except ValueError: - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT", - "Введите «да» или «нет».", - ) - ) - return - - group = await get_promo_group_by_id(db, data.get("edit_group_id")) - if not group: - await message.answer("❌ Промогруппа не найдена") - await state.clear() - return - - group = await update_promo_group(db, group, apply_addon_discounts=apply_addons) - await state.set_state(AdminStates.editing_promo_group_menu) - - await _send_edit_menu_after_update( - message, - texts, - group, - data.get("language", db_user.language), - texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), - ) - - @admin_required @error_handler async def process_edit_group_auto_assign( @@ -1368,10 +1235,6 @@ def register_handlers(dp: Dispatcher): process_create_group_period_discounts, AdminStates.creating_promo_group_period_discount, ) - dp.message.register( - process_create_group_addon_discounts, - AdminStates.creating_promo_group_addon_discount, - ) dp.message.register( process_create_group_auto_assign, AdminStates.creating_promo_group_auto_assign, @@ -1394,10 +1257,6 @@ def register_handlers(dp: Dispatcher): process_edit_group_period_discounts, AdminStates.editing_promo_group_period_discount, ) - dp.message.register( - process_edit_group_addon_discounts, - AdminStates.editing_promo_group_addon_discount, - ) dp.message.register( process_edit_group_auto_assign, AdminStates.editing_promo_group_auto_assign, diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 69b2b5ea..45fe983f 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -868,25 +868,16 @@ async def _render_user_promo_group( traffic=current_group.traffic_discount_percent, devices=current_group.device_discount_percent, ) - addon_line = texts.ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT.format( - state=( - texts.ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_ENABLED - if getattr(current_group, "apply_addon_discounts", True) - else texts.ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_DISABLED - ) - ) current_group_id = current_group.id else: current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT_NONE discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE - addon_line = texts.ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE current_group_id = None text = ( f"{texts.ADMIN_USER_PROMO_GROUP_TITLE}\n\n" f"{current_line}\n" - f"{discount_line}\n" - f"{addon_line}\n\n" + f"{discount_line}\n\n" f"{texts.ADMIN_USER_PROMO_GROUP_SELECT}" ) diff --git a/app/states.py b/app/states.py index 6bf45cf3..f824f9a5 100644 --- a/app/states.py +++ b/app/states.py @@ -69,7 +69,6 @@ class AdminStates(StatesGroup): creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() creating_promo_group_period_discount = State() - creating_promo_group_addon_discount = State() creating_promo_group_auto_assign = State() editing_promo_group_menu = State() @@ -78,7 +77,6 @@ class AdminStates(StatesGroup): editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() editing_promo_group_period_discount = State() - editing_promo_group_addon_discount = State() editing_promo_group_auto_assign = State() editing_squad_price = State() diff --git a/locales/en.json b/locales/en.json index df0e9343..f217ed28 100644 --- a/locales/en.json +++ b/locales/en.json @@ -151,8 +151,6 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🎁 Add-on discounts: applied", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🎁 Add-on discounts: not applied", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", @@ -229,10 +227,6 @@ "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned", "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT": "Add-on discounts: {state}", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "applied", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "not applied", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE": "Add-on discounts: no data", "ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:", "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”", "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.", @@ -250,10 +244,8 @@ "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", - "ADMIN_PROMO_GROUP_CREATE_ADDONS_PROMPT": "Apply discounts to add-on purchases? (yes/no)", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", - "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Please enter “yes” or “no”.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", @@ -261,12 +253,9 @@ "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100). Current value: {current}.", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100). Current value: {current}.", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", - "ADMIN_PROMO_GROUP_EDIT_ADDONS_PROMPT": "Apply discounts to add-on purchases? Current value: {current}. Enter yes/no.", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATE_ENABLED": "enabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATE_DISABLED": "disabled", "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”", "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:", "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename", @@ -274,7 +263,6 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDONS": "🎁 Add-on discounts", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.", diff --git a/locales/ru.json b/locales/ru.json index c50d95a3..2524c1d4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -17,8 +17,6 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🎁 Скидки на доп. услуги: применяются", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🎁 Скидки на доп. услуги: не применяются", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", @@ -95,10 +93,6 @@ "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена", "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT": "Скидки на доп. услуги: {state}", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "применяются", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "не применяются", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE": "Скидки на доп. услуги: нет данных", "ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:", "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»", "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.", @@ -116,10 +110,8 @@ "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", - "ADMIN_PROMO_GROUP_CREATE_ADDONS_PROMPT": "Включать скидки на докупку доп. услуг? (да/нет)", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", - "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Введите «да» или «нет».", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", @@ -127,12 +119,9 @@ "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100). Текущее значение: {current}.", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100). Текущее значение: {current}.", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", - "ADMIN_PROMO_GROUP_EDIT_ADDONS_PROMPT": "Включать скидки на докупку доп. услуг? Текущее значение: {current}. Введите да/нет.", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATE_ENABLED": "включены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATE_DISABLED": "выключены", "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»", "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:", "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название", @@ -140,7 +129,6 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDONS": "🎁 Скидки на доп. услуги", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.", From afab5f25157cfefc0aa94a237269313b8b7a7f86 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 12:46:04 +0300 Subject: [PATCH 108/146] fix: import addon discount helper in traffic switch --- app/database/crud/promo_group.py | 11 +- app/database/models.py | 26 ++++- app/database/universal_migration.py | 41 +++++++ app/handlers/admin/promo_groups.py | 159 +++++++++++++++++++++++++++ app/handlers/subscription.py | 94 +++++++++++++--- app/localization/locales/en.json | 8 ++ app/localization/locales/ru.json | 8 ++ app/services/subscription_service.py | 16 ++- app/states.py | 2 + locales/en.json | 8 ++ locales/ru.json | 8 ++ 11 files changed, 361 insertions(+), 20 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 3bc093f2..5e754056 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -60,6 +60,7 @@ async def create_promo_group( device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + addon_discounts_enabled: bool = True, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) @@ -69,6 +70,8 @@ async def create_promo_group( else None ) + addon_discounts_enabled = bool(addon_discounts_enabled) + promo_group = PromoGroup( name=name.strip(), server_discount_percent=max(0, min(100, server_discount_percent)), @@ -76,6 +79,7 @@ async def create_promo_group( device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, + addon_discounts_enabled=addon_discounts_enabled, is_default=False, ) @@ -84,13 +88,15 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s)" + " и порогом автоприсвоения %s₽ (скидки на доп. услуги: %s)", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, normalized_period_discounts, (auto_assign_total_spent_kopeks or 0) / 100, + addon_discounts_enabled, ) return promo_group @@ -106,6 +112,7 @@ async def update_promo_group( device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + addon_discounts_enabled: Optional[bool] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -120,6 +127,8 @@ async def update_promo_group( group.period_discounts = normalized_period_discounts or None if auto_assign_total_spent_kopeks is not None: group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) + if addon_discounts_enabled is not None: + group.addon_discounts_enabled = bool(addon_discounts_enabled) await db.commit() await db.refresh(group) diff --git a/app/database/models.py b/app/database/models.py index 0a3ad865..9992e1c2 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -292,6 +292,7 @@ class PromoGroup(Base): device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) + addon_discounts_enabled = Column(Boolean, nullable=False, default=True) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -346,7 +347,16 @@ class PromoGroup(Base): return 0 - def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int: + def get_discount_percent( + self, + category: str, + period_days: Optional[int] = None, + *, + for_addon: bool = False, + ) -> int: + if for_addon and category != "period" and not self.addon_discounts_enabled: + return 0 + if category == "period": return max(0, min(100, self._get_period_discount(period_days))) @@ -404,10 +414,20 @@ class User(Base): parts = [self.first_name, self.last_name] return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}" - def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int: + def get_promo_discount( + self, + category: str, + period_days: Optional[int] = None, + *, + for_addon: bool = False, + ) -> int: if not self.promo_group: return 0 - return self.promo_group.get_discount_percent(category, period_days) + return self.promo_group.get_discount_percent( + category, + period_days, + for_addon=for_addon, + ) def add_balance(self, kopeks: int) -> None: self.balance_kopeks += kopeks diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index b123c750..d8beea07 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -931,6 +931,44 @@ async def ensure_promo_groups_setup(): "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" ) + addon_discounts_column_exists = await check_column_exists( + "promo_groups", "addon_discounts_enabled" + ) + + if not addon_discounts_column_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN addon_discounts_enabled BOOLEAN NOT NULL DEFAULT 1" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET addon_discounts_enabled = 1 WHERE addon_discounts_enabled IS NULL" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN addon_discounts_enabled BOOLEAN NOT NULL DEFAULT TRUE" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN addon_discounts_enabled TINYINT(1) NOT NULL DEFAULT 1" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.addon_discounts_enabled: {db_type}" + ) + return False + + logger.info( + "Добавлена колонка promo_groups.addon_discounts_enabled" + ) + column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -1994,6 +2032,7 @@ async def check_migration_status(): "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, + "promo_groups_addon_discounts_column": False, "users_auto_promo_group_assigned_column": False, "subscription_crypto_link_column": False, } @@ -2011,6 +2050,7 @@ async def check_migration_status(): status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') + status["promo_groups_addon_discounts_column"] = await check_column_exists('promo_groups', 'addon_discounts_enabled') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') @@ -2048,6 +2088,7 @@ async def check_migration_status(): "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", + "promo_groups_addon_discounts_column": "Колонка addon_discounts_enabled у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 917f673f..a8a853d7 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -190,6 +190,21 @@ def _format_auto_assign_line(texts, group: PromoGroup) -> str: ).format(amount=amount) +def _format_addon_discount_line(texts, group: PromoGroup) -> str: + enabled = getattr(group, "addon_discounts_enabled", True) + key = ( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED" + if enabled + else "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED" + ) + default = ( + "Скидки на доп. услуги: включены" + if enabled + else "Скидки на доп. услуги: отключены" + ) + return texts.t(key, default) + + def _format_auto_assign_value(value_kopeks: Optional[int]) -> str: if not value_kopeks or value_kopeks <= 0: return "0" @@ -223,6 +238,20 @@ def _parse_auto_assign_threshold_input(value: str) -> int: return max(0, kopeks) +def _parse_addon_discount_input(value: str) -> bool: + normalized = (value or "").strip().lower() + + truthy = {"1", "true", "on", "yes", "да", "вкл", "y"} + falsy = {"0", "false", "off", "no", "нет", "выкл", "n"} + + if normalized in truthy: + return True + if normalized in falsy: + return False + + raise ValueError + + async def _prompt_for_auto_assign_threshold( message: types.Message, state: FSMContext, @@ -244,6 +273,27 @@ async def _prompt_for_auto_assign_threshold( await message.answer(prompt_text) +async def _prompt_for_addon_discount( + message: types.Message, + state: FSMContext, + prompt_key: str, + default_text: str, + *, + current_value: Optional[str] = None, +): + data = await state.get_data() + texts = get_texts(data.get("language", "ru")) + prompt_text = texts.t(prompt_key, default_text) + + if current_value is not None: + try: + prompt_text = prompt_text.format(current=current_value) + except KeyError: + pass + + await message.answer(prompt_text) + + def _build_edit_menu_content( texts, group: PromoGroup, @@ -257,6 +307,7 @@ def _build_edit_menu_content( lines = [ header, _format_discount_line(texts, group), + _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), ] @@ -309,6 +360,15 @@ def _build_edit_menu_content( callback_data=f"promo_group_edit_field_{group.id}_devices", ) ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON", + "💡 Скидки на доп. услуги", + ), + callback_data=f"promo_group_edit_field_{group.id}_addon", + ) + ], [ types.InlineKeyboardButton( text=texts.t( @@ -399,6 +459,7 @@ async def show_promo_groups_menu( group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), + _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", @@ -675,6 +736,39 @@ async def process_create_group_period_discounts( return await state.update_data(new_group_period_discounts=period_discounts) + await state.set_state(AdminStates.creating_promo_group_addon_discounts) + + await _prompt_for_addon_discount( + message, + state, + "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT", + "Включить скидки на доп. услуги при докупке? Отправьте 1 для включения или 0 для отключения.", + ) + + +@admin_required +@error_handler +async def process_create_group_addon_discounts( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + addon_enabled = _parse_addon_discount_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_ADDON", + "Введите 1, чтобы включить скидки, или 0, чтобы отключить.", + ) + ) + return + + await state.update_data(new_group_addon_discounts=addon_enabled) await state.set_state(AdminStates.creating_promo_group_auto_assign) await _prompt_for_auto_assign_threshold( @@ -716,6 +810,7 @@ async def process_create_group_auto_assign( device_discount_percent=data["new_group_devices"], period_discounts=data.get("new_group_period_discounts"), auto_assign_total_spent_kopeks=auto_assign_kopeks, + addon_discounts_enabled=data.get("new_group_addon_discounts", True), ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -819,6 +914,12 @@ async def prompt_edit_promo_group_field( "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT", "Введите новую скидку на устройства (текущее значение: {current}%):", ).format(current=group.device_discount_percent) + elif field == "addon": + await state.set_state(AdminStates.editing_promo_group_addon_discounts) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT", + "Отправьте 1 для включения скидок на доп. услуги или 0 для отключения. Сейчас: {current}.", + ).format(current=_format_addon_discount_line(texts, group)) elif field == "periods": await state.set_state(AdminStates.editing_promo_group_period_discount) current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) @@ -944,6 +1045,56 @@ async def process_edit_group_servers( ) +@admin_required +@error_handler +async def process_edit_group_addon_discounts( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + addon_enabled = _parse_addon_discount_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_ADDON", + "Введите 1, чтобы включить скидки, или 0, чтобы отключить.", + ) + ) + return + + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group( + db, + group, + addon_discounts_enabled=addon_enabled, + ) + await state.set_state(AdminStates.editing_promo_group_menu) + + success_key = ( + "ADMIN_PROMO_GROUP_ADDON_ENABLED_SUCCESS" + if addon_enabled + else "ADMIN_PROMO_GROUP_ADDON_DISABLED_SUCCESS" + ) + + await _send_edit_menu_after_update( + message, + texts, + group, + data.get("language", db_user.language), + texts.t(success_key, "Скидки на доп. услуги обновлены."), + ) + + @admin_required @error_handler async def process_edit_group_devices( @@ -1235,6 +1386,10 @@ def register_handlers(dp: Dispatcher): process_create_group_period_discounts, AdminStates.creating_promo_group_period_discount, ) + dp.message.register( + process_create_group_addon_discounts, + AdminStates.creating_promo_group_addon_discounts, + ) dp.message.register( process_create_group_auto_assign, AdminStates.creating_promo_group_auto_assign, @@ -1249,6 +1404,10 @@ def register_handlers(dp: Dispatcher): process_edit_group_servers, AdminStates.editing_promo_group_server_discount, ) + dp.message.register( + process_edit_group_addon_discounts, + AdminStates.editing_promo_group_addon_discounts, + ) dp.message.register( process_edit_group_devices, AdminStates.editing_promo_group_device_discount, diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 97b91d0b..103c93a5 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -1251,7 +1251,11 @@ async def apply_countries_changes( db: AsyncSession, state: FSMContext ): - from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price + from app.utils.pricing_utils import ( + get_remaining_months, + calculate_prorated_price, + apply_percentage_discount, + ) logger.info(f"🔧 Применение изменений стран") @@ -1292,23 +1296,38 @@ async def apply_countries_changes( cost_per_month = 0 added_names = [] removed_names = [] - + added_server_prices = [] - + servers_discount_percent = db_user.get_promo_discount( + "servers", + for_addon=True, + ) + discounted_prices_by_uuid: Dict[str, int] = {} + for country in countries: if country['uuid'] in added: server_price_per_month = country['price_kopeks'] - cost_per_month += server_price_per_month + discounted_per_month = server_price_per_month + if servers_discount_percent: + discounted_per_month, _ = apply_percentage_discount( + server_price_per_month, + servers_discount_percent, + ) + discounted_prices_by_uuid[country['uuid']] = discounted_per_month + cost_per_month += discounted_per_month added_names.append(country['name']) if country['uuid'] in removed: removed_names.append(country['name']) - + total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) - + for country in countries: if country['uuid'] in added: - server_price_per_month = country['price_kopeks'] - server_total_price = server_price_per_month * charged_months + discounted_per_month = discounted_prices_by_uuid.get( + country['uuid'], + country['price_kopeks'], + ) + server_total_price = discounted_per_month * charged_months added_server_prices.append(server_total_price) logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽") @@ -1493,7 +1512,11 @@ async def confirm_change_devices( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price + from app.utils.pricing_utils import ( + get_remaining_months, + calculate_prorated_price, + apply_percentage_discount, + ) new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) @@ -1524,6 +1547,15 @@ async def confirm_change_devices( chargeable_devices = additional_devices devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE + devices_discount_percent = db_user.get_promo_discount( + "devices", + for_addon=True, + ) + if devices_discount_percent: + devices_price_per_month, _ = apply_percentage_discount( + devices_price_per_month, + devices_discount_percent, + ) price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) if price > 0 and db_user.balance_kopeks < price: @@ -1584,7 +1616,11 @@ async def execute_change_devices( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price + from app.utils.pricing_utils import ( + get_remaining_months, + calculate_prorated_price, + apply_percentage_discount, + ) callback_parts = callback.data.split('_') new_devices_count = int(callback_parts[3]) @@ -2120,7 +2156,7 @@ async def confirm_add_devices( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price + from app.utils.pricing_utils import get_remaining_months, apply_percentage_discount devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) @@ -2139,6 +2175,15 @@ async def confirm_add_devices( return devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE + devices_discount_percent = db_user.get_promo_discount( + "devices", + for_addon=True, + ) + if devices_discount_percent: + devices_price_per_month, _ = apply_percentage_discount( + devices_price_per_month, + devices_discount_percent, + ) price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽") @@ -3496,12 +3541,20 @@ async def add_traffic( if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return - + traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription - + price = settings.get_traffic_price(traffic_gb) + traffic_discount_percent = db_user.get_promo_discount( + "traffic", + for_addon=True, + ) + if traffic_discount_percent: + from app.utils.pricing_utils import apply_percentage_discount + + price, _ = apply_percentage_discount(price, traffic_discount_percent) if price == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) @@ -4900,7 +4953,7 @@ async def confirm_switch_traffic( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price + from app.utils.pricing_utils import get_remaining_months, apply_percentage_discount new_traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) @@ -4914,6 +4967,19 @@ async def confirm_switch_traffic( old_price_per_month = settings.get_traffic_price(current_traffic) new_price_per_month = settings.get_traffic_price(new_traffic_gb) + traffic_discount_percent = db_user.get_promo_discount( + "traffic", + for_addon=True, + ) + if traffic_discount_percent: + old_price_per_month, _ = apply_percentage_discount( + old_price_per_month, + traffic_discount_percent, + ) + new_price_per_month, _ = apply_percentage_discount( + new_price_per_month, + traffic_discount_percent, + ) months_remaining = get_remaining_months(subscription.end_date) price_difference_per_month = new_price_per_month - old_price_per_month diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 98b41a5b..477d8c05 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -163,14 +163,22 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT": "Enable discounts for add-on services? Send 1 to enable or 0 to disable.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", + "ADMIN_PROMO_GROUP_INVALID_ADDON": "Send 1 to enable discounts or 0 to disable them.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON": "💡 Add-on service discounts", + "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT": "Send 1 to enable add-on discounts or 0 to disable them. Current: {current}.", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", + "ADMIN_PROMO_GROUP_ADDON_ENABLED_SUCCESS": "Add-on service discounts enabled.", + "ADMIN_PROMO_GROUP_ADDON_DISABLED_SUCCESS": "Add-on service discounts disabled.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Add-on discounts: enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Add-on discounts: disabled", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 831a55d1..940db090 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -40,14 +40,22 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT": "Включить скидки на доп. услуги при докупке? Отправьте 1 для включения или 0 для отключения.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", + "ADMIN_PROMO_GROUP_INVALID_ADDON": "Введите 1, чтобы включить скидки, или 0, чтобы отключить.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON": "💡 Скидки на доп. услуги", + "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT": "Отправьте 1 для включения скидок на доп. услуги или 0 для отключения. Сейчас: {current}.", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", + "ADMIN_PROMO_GROUP_ADDON_ENABLED_SUCCESS": "Скидки на доп. услуги включены.", + "ADMIN_PROMO_GROUP_ADDON_DISABLED_SUCCESS": "Скидки на доп. услуги отключены.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Скидки на доп. услуги: включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Скидки на доп. услуги: отключены", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 190a9470..14c719da 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -26,15 +26,24 @@ def _resolve_discount_percent( category: str, *, period_days: Optional[int] = None, + for_addon: bool = False, ) -> int: if user is not None: try: - return user.get_promo_discount(category, period_days) + return user.get_promo_discount( + category, + period_days, + for_addon=for_addon, + ) except AttributeError: pass if promo_group is not None: - return promo_group.get_discount_percent(category, period_days) + return promo_group.get_discount_percent( + category, + period_days, + for_addon=for_addon, + ) return 0 @@ -863,6 +872,7 @@ class SubscriptionService: promo_group, "traffic", period_days=period_hint_days, + for_addon=True, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month @@ -886,6 +896,7 @@ class SubscriptionService: promo_group, "devices", period_days=period_hint_days, + for_addon=True, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month @@ -913,6 +924,7 @@ class SubscriptionService: promo_group, "servers", period_days=period_hint_days, + for_addon=True, ) server_discount_per_month = ( server_price_per_month * servers_discount_percent // 100 diff --git a/app/states.py b/app/states.py index f824f9a5..9de381dd 100644 --- a/app/states.py +++ b/app/states.py @@ -69,6 +69,7 @@ class AdminStates(StatesGroup): creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() creating_promo_group_period_discount = State() + creating_promo_group_addon_discounts = State() creating_promo_group_auto_assign = State() editing_promo_group_menu = State() @@ -77,6 +78,7 @@ class AdminStates(StatesGroup): editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() editing_promo_group_period_discount = State() + editing_promo_group_addon_discounts = State() editing_promo_group_auto_assign = State() editing_squad_price = State() diff --git a/locales/en.json b/locales/en.json index f217ed28..5a151fc4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -243,8 +243,10 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT": "Enable discounts for add-on services? Send 1 to enable or 0 to disable.", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", + "ADMIN_PROMO_GROUP_INVALID_ADDON": "Send 1 to enable discounts or 0 to disable them.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", @@ -262,11 +264,17 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON": "💡 Add-on service discounts", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT": "Send 1 to enable add-on discounts or 0 to disable them. Current: {current}.", + "ADMIN_PROMO_GROUP_ADDON_ENABLED_SUCCESS": "Add-on service discounts enabled.", + "ADMIN_PROMO_GROUP_ADDON_DISABLED_SUCCESS": "Add-on service discounts disabled.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Add-on discounts: enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Add-on discounts: disabled", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", diff --git a/locales/ru.json b/locales/ru.json index 2524c1d4..df20c6fb 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -109,8 +109,10 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT": "Включить скидки на доп. услуги при докупке? Отправьте 1 для включения или 0 для отключения.", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", + "ADMIN_PROMO_GROUP_INVALID_ADDON": "Введите 1, чтобы включить скидки, или 0, чтобы отключить.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", @@ -128,11 +130,17 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик", "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON": "💡 Скидки на доп. услуги", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT": "Отправьте 1 для включения скидок на доп. услуги или 0 для отключения. Сейчас: {current}.", + "ADMIN_PROMO_GROUP_ADDON_ENABLED_SUCCESS": "Скидки на доп. услуги включены.", + "ADMIN_PROMO_GROUP_ADDON_DISABLED_SUCCESS": "Скидки на доп. услуги отключены.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Скидки на доп. услуги: включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Скидки на доп. услуги: отключены", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", From 06c90dc3cafb8d9888e2ca6dfbb27702fbe90be0 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 12:51:22 +0300 Subject: [PATCH 109/146] Revert "feat: add promo group addon discount toggle" --- app/database/crud/promo_group.py | 11 +- app/database/models.py | 26 +---- app/database/universal_migration.py | 41 ------- app/handlers/admin/promo_groups.py | 159 --------------------------- app/handlers/subscription.py | 94 +++------------- app/localization/locales/en.json | 8 -- app/localization/locales/ru.json | 8 -- app/services/subscription_service.py | 16 +-- app/states.py | 2 - locales/en.json | 8 -- locales/ru.json | 8 -- 11 files changed, 20 insertions(+), 361 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 5e754056..3bc093f2 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -60,7 +60,6 @@ async def create_promo_group( device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, - addon_discounts_enabled: bool = True, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) @@ -70,8 +69,6 @@ async def create_promo_group( else None ) - addon_discounts_enabled = bool(addon_discounts_enabled) - promo_group = PromoGroup( name=name.strip(), server_discount_percent=max(0, min(100, server_discount_percent)), @@ -79,7 +76,6 @@ async def create_promo_group( device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, - addon_discounts_enabled=addon_discounts_enabled, is_default=False, ) @@ -88,15 +84,13 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s)" - " и порогом автоприсвоения %s₽ (скидки на доп. услуги: %s)", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, normalized_period_discounts, (auto_assign_total_spent_kopeks or 0) / 100, - addon_discounts_enabled, ) return promo_group @@ -112,7 +106,6 @@ async def update_promo_group( device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, - addon_discounts_enabled: Optional[bool] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -127,8 +120,6 @@ async def update_promo_group( group.period_discounts = normalized_period_discounts or None if auto_assign_total_spent_kopeks is not None: group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) - if addon_discounts_enabled is not None: - group.addon_discounts_enabled = bool(addon_discounts_enabled) await db.commit() await db.refresh(group) diff --git a/app/database/models.py b/app/database/models.py index 9992e1c2..0a3ad865 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -292,7 +292,6 @@ class PromoGroup(Base): device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) - addon_discounts_enabled = Column(Boolean, nullable=False, default=True) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -347,16 +346,7 @@ class PromoGroup(Base): return 0 - def get_discount_percent( - self, - category: str, - period_days: Optional[int] = None, - *, - for_addon: bool = False, - ) -> int: - if for_addon and category != "period" and not self.addon_discounts_enabled: - return 0 - + def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int: if category == "period": return max(0, min(100, self._get_period_discount(period_days))) @@ -414,20 +404,10 @@ class User(Base): parts = [self.first_name, self.last_name] return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}" - def get_promo_discount( - self, - category: str, - period_days: Optional[int] = None, - *, - for_addon: bool = False, - ) -> int: + def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int: if not self.promo_group: return 0 - return self.promo_group.get_discount_percent( - category, - period_days, - for_addon=for_addon, - ) + return self.promo_group.get_discount_percent(category, period_days) def add_balance(self, kopeks: int) -> None: self.balance_kopeks += kopeks diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index d8beea07..b123c750 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -931,44 +931,6 @@ async def ensure_promo_groups_setup(): "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" ) - addon_discounts_column_exists = await check_column_exists( - "promo_groups", "addon_discounts_enabled" - ) - - if not addon_discounts_column_exists: - if db_type == "sqlite": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN addon_discounts_enabled BOOLEAN NOT NULL DEFAULT 1" - ) - ) - await conn.execute( - text( - "UPDATE promo_groups SET addon_discounts_enabled = 1 WHERE addon_discounts_enabled IS NULL" - ) - ) - elif db_type == "postgresql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN addon_discounts_enabled BOOLEAN NOT NULL DEFAULT TRUE" - ) - ) - elif db_type == "mysql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN addon_discounts_enabled TINYINT(1) NOT NULL DEFAULT 1" - ) - ) - else: - logger.error( - f"Неподдерживаемый тип БД для promo_groups.addon_discounts_enabled: {db_type}" - ) - return False - - logger.info( - "Добавлена колонка promo_groups.addon_discounts_enabled" - ) - column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -2032,7 +1994,6 @@ async def check_migration_status(): "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, - "promo_groups_addon_discounts_column": False, "users_auto_promo_group_assigned_column": False, "subscription_crypto_link_column": False, } @@ -2050,7 +2011,6 @@ async def check_migration_status(): status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') - status["promo_groups_addon_discounts_column"] = await check_column_exists('promo_groups', 'addon_discounts_enabled') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') @@ -2088,7 +2048,6 @@ async def check_migration_status(): "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", - "promo_groups_addon_discounts_column": "Колонка addon_discounts_enabled у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index a8a853d7..917f673f 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -190,21 +190,6 @@ def _format_auto_assign_line(texts, group: PromoGroup) -> str: ).format(amount=amount) -def _format_addon_discount_line(texts, group: PromoGroup) -> str: - enabled = getattr(group, "addon_discounts_enabled", True) - key = ( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED" - if enabled - else "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED" - ) - default = ( - "Скидки на доп. услуги: включены" - if enabled - else "Скидки на доп. услуги: отключены" - ) - return texts.t(key, default) - - def _format_auto_assign_value(value_kopeks: Optional[int]) -> str: if not value_kopeks or value_kopeks <= 0: return "0" @@ -238,20 +223,6 @@ def _parse_auto_assign_threshold_input(value: str) -> int: return max(0, kopeks) -def _parse_addon_discount_input(value: str) -> bool: - normalized = (value or "").strip().lower() - - truthy = {"1", "true", "on", "yes", "да", "вкл", "y"} - falsy = {"0", "false", "off", "no", "нет", "выкл", "n"} - - if normalized in truthy: - return True - if normalized in falsy: - return False - - raise ValueError - - async def _prompt_for_auto_assign_threshold( message: types.Message, state: FSMContext, @@ -273,27 +244,6 @@ async def _prompt_for_auto_assign_threshold( await message.answer(prompt_text) -async def _prompt_for_addon_discount( - message: types.Message, - state: FSMContext, - prompt_key: str, - default_text: str, - *, - current_value: Optional[str] = None, -): - data = await state.get_data() - texts = get_texts(data.get("language", "ru")) - prompt_text = texts.t(prompt_key, default_text) - - if current_value is not None: - try: - prompt_text = prompt_text.format(current=current_value) - except KeyError: - pass - - await message.answer(prompt_text) - - def _build_edit_menu_content( texts, group: PromoGroup, @@ -307,7 +257,6 @@ def _build_edit_menu_content( lines = [ header, _format_discount_line(texts, group), - _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), ] @@ -360,15 +309,6 @@ def _build_edit_menu_content( callback_data=f"promo_group_edit_field_{group.id}_devices", ) ], - [ - types.InlineKeyboardButton( - text=texts.t( - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON", - "💡 Скидки на доп. услуги", - ), - callback_data=f"promo_group_edit_field_{group.id}_addon", - ) - ], [ types.InlineKeyboardButton( text=texts.t( @@ -459,7 +399,6 @@ async def show_promo_groups_menu( group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), - _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", @@ -736,39 +675,6 @@ async def process_create_group_period_discounts( return await state.update_data(new_group_period_discounts=period_discounts) - await state.set_state(AdminStates.creating_promo_group_addon_discounts) - - await _prompt_for_addon_discount( - message, - state, - "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT", - "Включить скидки на доп. услуги при докупке? Отправьте 1 для включения или 0 для отключения.", - ) - - -@admin_required -@error_handler -async def process_create_group_addon_discounts( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - addon_enabled = _parse_addon_discount_input(message.text) - except ValueError: - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_INVALID_ADDON", - "Введите 1, чтобы включить скидки, или 0, чтобы отключить.", - ) - ) - return - - await state.update_data(new_group_addon_discounts=addon_enabled) await state.set_state(AdminStates.creating_promo_group_auto_assign) await _prompt_for_auto_assign_threshold( @@ -810,7 +716,6 @@ async def process_create_group_auto_assign( device_discount_percent=data["new_group_devices"], period_discounts=data.get("new_group_period_discounts"), auto_assign_total_spent_kopeks=auto_assign_kopeks, - addon_discounts_enabled=data.get("new_group_addon_discounts", True), ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -914,12 +819,6 @@ async def prompt_edit_promo_group_field( "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT", "Введите новую скидку на устройства (текущее значение: {current}%):", ).format(current=group.device_discount_percent) - elif field == "addon": - await state.set_state(AdminStates.editing_promo_group_addon_discounts) - prompt = texts.t( - "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT", - "Отправьте 1 для включения скидок на доп. услуги или 0 для отключения. Сейчас: {current}.", - ).format(current=_format_addon_discount_line(texts, group)) elif field == "periods": await state.set_state(AdminStates.editing_promo_group_period_discount) current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) @@ -1045,56 +944,6 @@ async def process_edit_group_servers( ) -@admin_required -@error_handler -async def process_edit_group_addon_discounts( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - addon_enabled = _parse_addon_discount_input(message.text) - except ValueError: - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_INVALID_ADDON", - "Введите 1, чтобы включить скидки, или 0, чтобы отключить.", - ) - ) - return - - group = await get_promo_group_by_id(db, data.get("edit_group_id")) - if not group: - await message.answer("❌ Промогруппа не найдена") - await state.clear() - return - - group = await update_promo_group( - db, - group, - addon_discounts_enabled=addon_enabled, - ) - await state.set_state(AdminStates.editing_promo_group_menu) - - success_key = ( - "ADMIN_PROMO_GROUP_ADDON_ENABLED_SUCCESS" - if addon_enabled - else "ADMIN_PROMO_GROUP_ADDON_DISABLED_SUCCESS" - ) - - await _send_edit_menu_after_update( - message, - texts, - group, - data.get("language", db_user.language), - texts.t(success_key, "Скидки на доп. услуги обновлены."), - ) - - @admin_required @error_handler async def process_edit_group_devices( @@ -1386,10 +1235,6 @@ def register_handlers(dp: Dispatcher): process_create_group_period_discounts, AdminStates.creating_promo_group_period_discount, ) - dp.message.register( - process_create_group_addon_discounts, - AdminStates.creating_promo_group_addon_discounts, - ) dp.message.register( process_create_group_auto_assign, AdminStates.creating_promo_group_auto_assign, @@ -1404,10 +1249,6 @@ def register_handlers(dp: Dispatcher): process_edit_group_servers, AdminStates.editing_promo_group_server_discount, ) - dp.message.register( - process_edit_group_addon_discounts, - AdminStates.editing_promo_group_addon_discounts, - ) dp.message.register( process_edit_group_devices, AdminStates.editing_promo_group_device_discount, diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 103c93a5..97b91d0b 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -1251,11 +1251,7 @@ async def apply_countries_changes( db: AsyncSession, state: FSMContext ): - from app.utils.pricing_utils import ( - get_remaining_months, - calculate_prorated_price, - apply_percentage_discount, - ) + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price logger.info(f"🔧 Применение изменений стран") @@ -1296,38 +1292,23 @@ async def apply_countries_changes( cost_per_month = 0 added_names = [] removed_names = [] - + added_server_prices = [] - servers_discount_percent = db_user.get_promo_discount( - "servers", - for_addon=True, - ) - discounted_prices_by_uuid: Dict[str, int] = {} - + for country in countries: if country['uuid'] in added: server_price_per_month = country['price_kopeks'] - discounted_per_month = server_price_per_month - if servers_discount_percent: - discounted_per_month, _ = apply_percentage_discount( - server_price_per_month, - servers_discount_percent, - ) - discounted_prices_by_uuid[country['uuid']] = discounted_per_month - cost_per_month += discounted_per_month + cost_per_month += server_price_per_month added_names.append(country['name']) if country['uuid'] in removed: removed_names.append(country['name']) - + total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) - + for country in countries: if country['uuid'] in added: - discounted_per_month = discounted_prices_by_uuid.get( - country['uuid'], - country['price_kopeks'], - ) - server_total_price = discounted_per_month * charged_months + server_price_per_month = country['price_kopeks'] + server_total_price = server_price_per_month * charged_months added_server_prices.append(server_total_price) logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽") @@ -1512,11 +1493,7 @@ async def confirm_change_devices( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import ( - get_remaining_months, - calculate_prorated_price, - apply_percentage_discount, - ) + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) @@ -1547,15 +1524,6 @@ async def confirm_change_devices( chargeable_devices = additional_devices devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = db_user.get_promo_discount( - "devices", - for_addon=True, - ) - if devices_discount_percent: - devices_price_per_month, _ = apply_percentage_discount( - devices_price_per_month, - devices_discount_percent, - ) price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) if price > 0 and db_user.balance_kopeks < price: @@ -1616,11 +1584,7 @@ async def execute_change_devices( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import ( - get_remaining_months, - calculate_prorated_price, - apply_percentage_discount, - ) + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price callback_parts = callback.data.split('_') new_devices_count = int(callback_parts[3]) @@ -2156,7 +2120,7 @@ async def confirm_add_devices( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import get_remaining_months, apply_percentage_discount + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) @@ -2175,15 +2139,6 @@ async def confirm_add_devices( return devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE - devices_discount_percent = db_user.get_promo_discount( - "devices", - for_addon=True, - ) - if devices_discount_percent: - devices_price_per_month, _ = apply_percentage_discount( - devices_price_per_month, - devices_discount_percent, - ) price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽") @@ -3541,20 +3496,12 @@ async def add_traffic( if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return - + traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription - + price = settings.get_traffic_price(traffic_gb) - traffic_discount_percent = db_user.get_promo_discount( - "traffic", - for_addon=True, - ) - if traffic_discount_percent: - from app.utils.pricing_utils import apply_percentage_discount - - price, _ = apply_percentage_discount(price, traffic_discount_percent) if price == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) @@ -4953,7 +4900,7 @@ async def confirm_switch_traffic( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import get_remaining_months, apply_percentage_discount + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price new_traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) @@ -4967,19 +4914,6 @@ async def confirm_switch_traffic( old_price_per_month = settings.get_traffic_price(current_traffic) new_price_per_month = settings.get_traffic_price(new_traffic_gb) - traffic_discount_percent = db_user.get_promo_discount( - "traffic", - for_addon=True, - ) - if traffic_discount_percent: - old_price_per_month, _ = apply_percentage_discount( - old_price_per_month, - traffic_discount_percent, - ) - new_price_per_month, _ = apply_percentage_discount( - new_price_per_month, - traffic_discount_percent, - ) months_remaining = get_remaining_months(subscription.end_date) price_difference_per_month = new_price_per_month - old_price_per_month diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 477d8c05..98b41a5b 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -163,22 +163,14 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT": "Enable discounts for add-on services? Send 1 to enable or 0 to disable.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", - "ADMIN_PROMO_GROUP_INVALID_ADDON": "Send 1 to enable discounts or 0 to disable them.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON": "💡 Add-on service discounts", - "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT": "Send 1 to enable add-on discounts or 0 to disable them. Current: {current}.", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", - "ADMIN_PROMO_GROUP_ADDON_ENABLED_SUCCESS": "Add-on service discounts enabled.", - "ADMIN_PROMO_GROUP_ADDON_DISABLED_SUCCESS": "Add-on service discounts disabled.", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Add-on discounts: enabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Add-on discounts: disabled", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 940db090..831a55d1 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -40,22 +40,14 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT": "Включить скидки на доп. услуги при докупке? Отправьте 1 для включения или 0 для отключения.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", - "ADMIN_PROMO_GROUP_INVALID_ADDON": "Введите 1, чтобы включить скидки, или 0, чтобы отключить.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON": "💡 Скидки на доп. услуги", - "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT": "Отправьте 1 для включения скидок на доп. услуги или 0 для отключения. Сейчас: {current}.", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", - "ADMIN_PROMO_GROUP_ADDON_ENABLED_SUCCESS": "Скидки на доп. услуги включены.", - "ADMIN_PROMO_GROUP_ADDON_DISABLED_SUCCESS": "Скидки на доп. услуги отключены.", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Скидки на доп. услуги: включены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Скидки на доп. услуги: отключены", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 14c719da..190a9470 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -26,24 +26,15 @@ def _resolve_discount_percent( category: str, *, period_days: Optional[int] = None, - for_addon: bool = False, ) -> int: if user is not None: try: - return user.get_promo_discount( - category, - period_days, - for_addon=for_addon, - ) + return user.get_promo_discount(category, period_days) except AttributeError: pass if promo_group is not None: - return promo_group.get_discount_percent( - category, - period_days, - for_addon=for_addon, - ) + return promo_group.get_discount_percent(category, period_days) return 0 @@ -872,7 +863,6 @@ class SubscriptionService: promo_group, "traffic", period_days=period_hint_days, - for_addon=True, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month @@ -896,7 +886,6 @@ class SubscriptionService: promo_group, "devices", period_days=period_hint_days, - for_addon=True, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month @@ -924,7 +913,6 @@ class SubscriptionService: promo_group, "servers", period_days=period_hint_days, - for_addon=True, ) server_discount_per_month = ( server_price_per_month * servers_discount_percent // 100 diff --git a/app/states.py b/app/states.py index 9de381dd..f824f9a5 100644 --- a/app/states.py +++ b/app/states.py @@ -69,7 +69,6 @@ class AdminStates(StatesGroup): creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() creating_promo_group_period_discount = State() - creating_promo_group_addon_discounts = State() creating_promo_group_auto_assign = State() editing_promo_group_menu = State() @@ -78,7 +77,6 @@ class AdminStates(StatesGroup): editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() editing_promo_group_period_discount = State() - editing_promo_group_addon_discounts = State() editing_promo_group_auto_assign = State() editing_squad_price = State() diff --git a/locales/en.json b/locales/en.json index 5a151fc4..f217ed28 100644 --- a/locales/en.json +++ b/locales/en.json @@ -243,10 +243,8 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT": "Enable discounts for add-on services? Send 1 to enable or 0 to disable.", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", - "ADMIN_PROMO_GROUP_INVALID_ADDON": "Send 1 to enable discounts or 0 to disable them.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", @@ -264,17 +262,11 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON": "💡 Add-on service discounts", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.", - "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT": "Send 1 to enable add-on discounts or 0 to disable them. Current: {current}.", - "ADMIN_PROMO_GROUP_ADDON_ENABLED_SUCCESS": "Add-on service discounts enabled.", - "ADMIN_PROMO_GROUP_ADDON_DISABLED_SUCCESS": "Add-on service discounts disabled.", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Add-on discounts: enabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Add-on discounts: disabled", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", diff --git a/locales/ru.json b/locales/ru.json index df20c6fb..2524c1d4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -109,10 +109,8 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT": "Включить скидки на доп. услуги при докупке? Отправьте 1 для включения или 0 для отключения.", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", - "ADMIN_PROMO_GROUP_INVALID_ADDON": "Введите 1, чтобы включить скидки, или 0, чтобы отключить.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", @@ -130,17 +128,11 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик", "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON": "💡 Скидки на доп. услуги", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", - "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT": "Отправьте 1 для включения скидок на доп. услуги или 0 для отключения. Сейчас: {current}.", - "ADMIN_PROMO_GROUP_ADDON_ENABLED_SUCCESS": "Скидки на доп. услуги включены.", - "ADMIN_PROMO_GROUP_ADDON_DISABLED_SUCCESS": "Скидки на доп. услуги отключены.", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Скидки на доп. услуги: включены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Скидки на доп. услуги: отключены", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", From 80efd4ab86fcb7bacfcec5ea8fb3c7e0a96ccf6c Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 13:03:31 +0300 Subject: [PATCH 110/146] Add addon discount toggle for promo groups --- app/database/crud/promo_group.py | 8 ++- app/database/models.py | 1 + app/database/universal_migration.py | 51 +++++++++++++++++++ app/handlers/admin/promo_groups.py | 76 ++++++++++++++++++++++++++++ app/localization/locales/en.json | 6 +++ app/localization/locales/ru.json | 6 +++ app/services/subscription_service.py | 26 ++++++++-- locales/en.json | 6 +++ locales/ru.json | 6 +++ 9 files changed, 182 insertions(+), 4 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 3bc093f2..9296dd48 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -60,6 +60,7 @@ async def create_promo_group( device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + apply_discounts_to_addons: bool = True, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) @@ -76,6 +77,7 @@ async def create_promo_group( device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, + apply_discounts_to_addons=bool(apply_discounts_to_addons), is_default=False, ) @@ -84,13 +86,14 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, normalized_period_discounts, (auto_assign_total_spent_kopeks or 0) / 100, + "on" if promo_group.apply_discounts_to_addons else "off", ) return promo_group @@ -106,6 +109,7 @@ async def update_promo_group( device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + apply_discounts_to_addons: Optional[bool] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -120,6 +124,8 @@ async def update_promo_group( group.period_discounts = normalized_period_discounts or None if auto_assign_total_spent_kopeks is not None: group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) + if apply_discounts_to_addons is not None: + group.apply_discounts_to_addons = bool(apply_discounts_to_addons) await db.commit() await db.refresh(group) diff --git a/app/database/models.py b/app/database/models.py index 0a3ad865..278178ff 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -292,6 +292,7 @@ class PromoGroup(Base): device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) + apply_discounts_to_addons = Column(Boolean, nullable=False, default=True) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index b123c750..8f5648c5 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -931,6 +931,54 @@ async def ensure_promo_groups_setup(): "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" ) + addon_discount_column_exists = await check_column_exists( + "promo_groups", "apply_discounts_to_addons" + ) + + if not addon_discount_column_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT 1" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT TRUE" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET apply_discounts_to_addons = TRUE WHERE apply_discounts_to_addons IS NULL" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons TINYINT(1) NOT NULL DEFAULT 1" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.apply_discounts_to_addons: {db_type}" + ) + return False + + logger.info( + "Добавлена колонка promo_groups.apply_discounts_to_addons" + ) + column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -1994,6 +2042,7 @@ async def check_migration_status(): "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, + "promo_groups_addon_discount_column": False, "users_auto_promo_group_assigned_column": False, "subscription_crypto_link_column": False, } @@ -2011,6 +2060,7 @@ async def check_migration_status(): status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') + status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') @@ -2048,6 +2098,7 @@ async def check_migration_status(): "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", + "promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 917f673f..d21b9249 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -39,6 +39,32 @@ def _format_discount_line(texts, group) -> str: ) +def _format_addon_discounts_line(texts, group: PromoGroup) -> str: + enabled = getattr(group, "apply_discounts_to_addons", True) + if enabled: + return texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED", + "Скидки на доп. услуги: включены", + ) + return texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED", + "Скидки на доп. услуги: отключены", + ) + + +def _get_addon_discounts_button_text(texts, group: PromoGroup) -> str: + enabled = getattr(group, "apply_discounts_to_addons", True) + if enabled: + return texts.t( + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE", + "🧩 Отключить скидки на доп. услуги", + ) + return texts.t( + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE", + "🧩 Включить скидки на доп. услуги", + ) + + def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: if not raw or not isinstance(raw, dict): return {} @@ -257,6 +283,7 @@ def _build_edit_menu_content( lines = [ header, _format_discount_line(texts, group), + _format_addon_discounts_line(texts, group), _format_auto_assign_line(texts, group), ] @@ -318,6 +345,12 @@ def _build_edit_menu_content( callback_data=f"promo_group_edit_field_{group.id}_periods", ) ], + [ + types.InlineKeyboardButton( + text=_get_addon_discounts_button_text(texts, group), + callback_data=f"promo_group_toggle_addons_{group.id}", + ) + ], [ types.InlineKeyboardButton( text=texts.t( @@ -1192,6 +1225,45 @@ async def delete_promo_group_confirmed( await callback.answer() +@admin_required +@error_handler +async def toggle_promo_group_addon_discounts( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + group = await _get_group_or_alert(callback, db) + if not group: + return + + texts = get_texts(db_user.language) + + new_value = not getattr(group, "apply_discounts_to_addons", True) + + group = await update_promo_group( + db, + group, + apply_discounts_to_addons=new_value, + ) + + status_text = texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED" + if new_value + else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED", + "Скидки на докупку доп. услуг {status}.", + ).format(status="включены" if new_value else "отключены") + + await _send_edit_menu_after_update( + callback.message, + texts, + group, + db_user.language, + status_text, + ) + + await callback.answer() + + def register_handlers(dp: Dispatcher): dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups") dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_")) @@ -1200,6 +1272,10 @@ def register_handlers(dp: Dispatcher): prompt_edit_promo_group_field, F.data.startswith("promo_group_edit_field_"), ) + dp.callback_query.register( + toggle_promo_group_addon_discounts, + F.data.startswith("promo_group_toggle_addons_"), + ) dp.callback_query.register( start_edit_promo_group, F.data.regexp(r"^promo_group_edit_\d+$"), diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 98b41a5b..e2aa4e2a 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -138,6 +138,12 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Add-on purchase discounts have been disabled.", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 831a55d1..669987a3 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -15,6 +15,12 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Скидки на докупку доп. услуг отключены.", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 190a9470..54ba6720 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -38,6 +38,26 @@ def _resolve_discount_percent( return 0 + +def _resolve_addon_discount_percent( + user: Optional[User], + promo_group: Optional[PromoGroup], + category: str, + *, + period_days: Optional[int] = None, +) -> int: + group = promo_group or (getattr(user, "promo_group", None) if user else None) + + if group is not None and not getattr(group, "apply_discounts_to_addons", True): + return 0 + + return _resolve_discount_percent( + user, + promo_group, + category, + period_days=period_days, + ) + def get_traffic_reset_strategy(): from app.config import settings strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper() @@ -858,7 +878,7 @@ class SubscriptionService: if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_discount_percent = _resolve_discount_percent( + traffic_discount_percent = _resolve_addon_discount_percent( user, promo_group, "traffic", @@ -881,7 +901,7 @@ class SubscriptionService: if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _resolve_discount_percent( + devices_discount_percent = _resolve_addon_discount_percent( user, promo_group, "devices", @@ -908,7 +928,7 @@ class SubscriptionService: server = await get_server_squad_by_id(db, server_id) if server and server.is_available: server_price_per_month = server.price_kopeks - servers_discount_percent = _resolve_discount_percent( + servers_discount_percent = _resolve_addon_discount_percent( user, promo_group, "servers", diff --git a/locales/en.json b/locales/en.json index f217ed28..110c9ae9 100644 --- a/locales/en.json +++ b/locales/en.json @@ -151,6 +151,12 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Add-on purchase discounts have been disabled.", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", diff --git a/locales/ru.json b/locales/ru.json index 2524c1d4..51eab11b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -17,6 +17,12 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Скидки на докупку доп. услуг отключены.", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", From 18556f54d3e1f883b450f2e913ca459961964c0d Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 13:05:54 +0300 Subject: [PATCH 111/146] Revert "Add addon discount toggle for promo groups" --- app/database/crud/promo_group.py | 8 +-- app/database/models.py | 1 - app/database/universal_migration.py | 51 ------------------- app/handlers/admin/promo_groups.py | 76 ---------------------------- app/localization/locales/en.json | 6 --- app/localization/locales/ru.json | 6 --- app/services/subscription_service.py | 26 ++-------- locales/en.json | 6 --- locales/ru.json | 6 --- 9 files changed, 4 insertions(+), 182 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 9296dd48..3bc093f2 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -60,7 +60,6 @@ async def create_promo_group( device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, - apply_discounts_to_addons: bool = True, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) @@ -77,7 +76,6 @@ async def create_promo_group( device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, - apply_discounts_to_addons=bool(apply_discounts_to_addons), is_default=False, ) @@ -86,14 +84,13 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, normalized_period_discounts, (auto_assign_total_spent_kopeks or 0) / 100, - "on" if promo_group.apply_discounts_to_addons else "off", ) return promo_group @@ -109,7 +106,6 @@ async def update_promo_group( device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, - apply_discounts_to_addons: Optional[bool] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -124,8 +120,6 @@ async def update_promo_group( group.period_discounts = normalized_period_discounts or None if auto_assign_total_spent_kopeks is not None: group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) - if apply_discounts_to_addons is not None: - group.apply_discounts_to_addons = bool(apply_discounts_to_addons) await db.commit() await db.refresh(group) diff --git a/app/database/models.py b/app/database/models.py index 278178ff..0a3ad865 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -292,7 +292,6 @@ class PromoGroup(Base): device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) - apply_discounts_to_addons = Column(Boolean, nullable=False, default=True) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 8f5648c5..b123c750 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -931,54 +931,6 @@ async def ensure_promo_groups_setup(): "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" ) - addon_discount_column_exists = await check_column_exists( - "promo_groups", "apply_discounts_to_addons" - ) - - if not addon_discount_column_exists: - if db_type == "sqlite": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT 1" - ) - ) - await conn.execute( - text( - "UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL" - ) - ) - elif db_type == "postgresql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT TRUE" - ) - ) - await conn.execute( - text( - "UPDATE promo_groups SET apply_discounts_to_addons = TRUE WHERE apply_discounts_to_addons IS NULL" - ) - ) - elif db_type == "mysql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons TINYINT(1) NOT NULL DEFAULT 1" - ) - ) - await conn.execute( - text( - "UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL" - ) - ) - else: - logger.error( - f"Неподдерживаемый тип БД для promo_groups.apply_discounts_to_addons: {db_type}" - ) - return False - - logger.info( - "Добавлена колонка promo_groups.apply_discounts_to_addons" - ) - column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -2042,7 +1994,6 @@ async def check_migration_status(): "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, - "promo_groups_addon_discount_column": False, "users_auto_promo_group_assigned_column": False, "subscription_crypto_link_column": False, } @@ -2060,7 +2011,6 @@ async def check_migration_status(): status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') - status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') @@ -2098,7 +2048,6 @@ async def check_migration_status(): "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", - "promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index d21b9249..917f673f 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -39,32 +39,6 @@ def _format_discount_line(texts, group) -> str: ) -def _format_addon_discounts_line(texts, group: PromoGroup) -> str: - enabled = getattr(group, "apply_discounts_to_addons", True) - if enabled: - return texts.t( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED", - "Скидки на доп. услуги: включены", - ) - return texts.t( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED", - "Скидки на доп. услуги: отключены", - ) - - -def _get_addon_discounts_button_text(texts, group: PromoGroup) -> str: - enabled = getattr(group, "apply_discounts_to_addons", True) - if enabled: - return texts.t( - "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE", - "🧩 Отключить скидки на доп. услуги", - ) - return texts.t( - "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE", - "🧩 Включить скидки на доп. услуги", - ) - - def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: if not raw or not isinstance(raw, dict): return {} @@ -283,7 +257,6 @@ def _build_edit_menu_content( lines = [ header, _format_discount_line(texts, group), - _format_addon_discounts_line(texts, group), _format_auto_assign_line(texts, group), ] @@ -345,12 +318,6 @@ def _build_edit_menu_content( callback_data=f"promo_group_edit_field_{group.id}_periods", ) ], - [ - types.InlineKeyboardButton( - text=_get_addon_discounts_button_text(texts, group), - callback_data=f"promo_group_toggle_addons_{group.id}", - ) - ], [ types.InlineKeyboardButton( text=texts.t( @@ -1225,45 +1192,6 @@ async def delete_promo_group_confirmed( await callback.answer() -@admin_required -@error_handler -async def toggle_promo_group_addon_discounts( - callback: types.CallbackQuery, - db_user, - db: AsyncSession, -): - group = await _get_group_or_alert(callback, db) - if not group: - return - - texts = get_texts(db_user.language) - - new_value = not getattr(group, "apply_discounts_to_addons", True) - - group = await update_promo_group( - db, - group, - apply_discounts_to_addons=new_value, - ) - - status_text = texts.t( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED" - if new_value - else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED", - "Скидки на докупку доп. услуг {status}.", - ).format(status="включены" if new_value else "отключены") - - await _send_edit_menu_after_update( - callback.message, - texts, - group, - db_user.language, - status_text, - ) - - await callback.answer() - - def register_handlers(dp: Dispatcher): dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups") dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_")) @@ -1272,10 +1200,6 @@ def register_handlers(dp: Dispatcher): prompt_edit_promo_group_field, F.data.startswith("promo_group_edit_field_"), ) - dp.callback_query.register( - toggle_promo_group_addon_discounts, - F.data.startswith("promo_group_toggle_addons_"), - ) dp.callback_query.register( start_edit_promo_group, F.data.regexp(r"^promo_group_edit_\d+$"), diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index e2aa4e2a..98b41a5b 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -138,12 +138,6 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled", - "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", - "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Add-on purchase discounts have been disabled.", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 669987a3..831a55d1 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -15,12 +15,6 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены", - "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", - "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Скидки на докупку доп. услуг отключены.", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 54ba6720..190a9470 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -38,26 +38,6 @@ def _resolve_discount_percent( return 0 - -def _resolve_addon_discount_percent( - user: Optional[User], - promo_group: Optional[PromoGroup], - category: str, - *, - period_days: Optional[int] = None, -) -> int: - group = promo_group or (getattr(user, "promo_group", None) if user else None) - - if group is not None and not getattr(group, "apply_discounts_to_addons", True): - return 0 - - return _resolve_discount_percent( - user, - promo_group, - category, - period_days=period_days, - ) - def get_traffic_reset_strategy(): from app.config import settings strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper() @@ -878,7 +858,7 @@ class SubscriptionService: if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_discount_percent = _resolve_addon_discount_percent( + traffic_discount_percent = _resolve_discount_percent( user, promo_group, "traffic", @@ -901,7 +881,7 @@ class SubscriptionService: if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _resolve_addon_discount_percent( + devices_discount_percent = _resolve_discount_percent( user, promo_group, "devices", @@ -928,7 +908,7 @@ class SubscriptionService: server = await get_server_squad_by_id(db, server_id) if server and server.is_available: server_price_per_month = server.price_kopeks - servers_discount_percent = _resolve_addon_discount_percent( + servers_discount_percent = _resolve_discount_percent( user, promo_group, "servers", diff --git a/locales/en.json b/locales/en.json index 110c9ae9..f217ed28 100644 --- a/locales/en.json +++ b/locales/en.json @@ -151,12 +151,6 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled", - "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", - "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Add-on purchase discounts have been disabled.", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", diff --git a/locales/ru.json b/locales/ru.json index 51eab11b..2524c1d4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -17,12 +17,6 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены", - "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", - "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Скидки на докупку доп. услуг отключены.", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", From b53a54a237b3b87d517c0e7abd5b34272c896364 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 13:07:03 +0300 Subject: [PATCH 112/146] feat: add toggle for promo group addon discounts --- app/database/crud/promo_group.py | 5 + app/database/crud/subscription.py | 22 +- app/database/models.py | 1 + app/database/universal_migration.py | 51 ++ app/handlers/admin/promo_groups.py | 164 +++++ app/handlers/admin/users.py | 18 +- app/localization/locales/en.json | 619 ++++++++-------- app/localization/locales/ru.json | 437 +++++------ app/states.py | 2 + locales/en.json | 1055 ++++++++++++++------------- locales/ru.json | 1055 ++++++++++++++------------- 11 files changed, 1858 insertions(+), 1571 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 3bc093f2..9b927f01 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -60,6 +60,7 @@ async def create_promo_group( device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + addon_discounts_enabled: bool = True, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) @@ -77,6 +78,7 @@ async def create_promo_group( period_discounts=normalized_period_discounts or None, auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, is_default=False, + addon_discounts_enabled=addon_discounts_enabled, ) db.add(promo_group) @@ -106,6 +108,7 @@ async def update_promo_group( device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + addon_discounts_enabled: Optional[bool] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -120,6 +123,8 @@ async def update_promo_group( group.period_discounts = normalized_period_discounts or None if auto_assign_total_spent_kopeks is not None: group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) + if addon_discounts_enabled is not None: + group.addon_discounts_enabled = bool(addon_discounts_enabled) await db.commit() await db.refresh(group) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 91b79375..06385da8 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -504,17 +504,26 @@ def _get_discount_percent( category: str, *, period_days: Optional[int] = None, + for_addon: bool = False, ) -> int: + effective_group = promo_group or getattr(user, "promo_group", None) + + percent = 0 if user is not None: try: - return user.get_promo_discount(category, period_days) + percent = user.get_promo_discount(category, period_days) except AttributeError: - pass + percent = 0 - if promo_group is not None: - return promo_group.get_discount_percent(category, period_days) + if percent == 0 and promo_group is not None: + percent = promo_group.get_discount_percent(category, period_days) - return 0 + if for_addon and effective_group is not None and not getattr( + effective_group, "addon_discounts_enabled", True + ): + return 0 + + return percent async def calculate_subscription_total_cost( @@ -852,6 +861,7 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "traffic", period_days=period_hint_days, + for_addon=True, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month @@ -873,6 +883,7 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "devices", period_days=period_hint_days, + for_addon=True, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month @@ -902,6 +913,7 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "servers", period_days=period_hint_days, + for_addon=True, ) server_discount_per_month = server_price_per_month * servers_discount_percent // 100 discounted_server_per_month = server_price_per_month - server_discount_per_month diff --git a/app/database/models.py b/app/database/models.py index 0a3ad865..d33a9347 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -291,6 +291,7 @@ class PromoGroup(Base): traffic_discount_percent = Column(Integer, nullable=False, default=0) device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) + addon_discounts_enabled = Column(Boolean, nullable=False, default=True) auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index b123c750..c6171c72 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -931,6 +931,54 @@ async def ensure_promo_groups_setup(): "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" ) + addon_discounts_column_exists = await check_column_exists( + "promo_groups", "addon_discounts_enabled" + ) + + if not addon_discounts_column_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN addon_discounts_enabled BOOLEAN NOT NULL DEFAULT 1" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET addon_discounts_enabled = 1 WHERE addon_discounts_enabled IS NULL" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN addon_discounts_enabled BOOLEAN NOT NULL DEFAULT TRUE" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET addon_discounts_enabled = TRUE WHERE addon_discounts_enabled IS NULL" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN addon_discounts_enabled TINYINT(1) NOT NULL DEFAULT 1" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET addon_discounts_enabled = 1 WHERE addon_discounts_enabled IS NULL" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.addon_discounts_enabled: {db_type}" + ) + return False + + logger.info( + "Добавлена колонка promo_groups.addon_discounts_enabled" + ) + column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -1994,6 +2042,7 @@ async def check_migration_status(): "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, + "promo_groups_addon_discounts_column": False, "users_auto_promo_group_assigned_column": False, "subscription_crypto_link_column": False, } @@ -2011,6 +2060,7 @@ async def check_migration_status(): status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') + status["promo_groups_addon_discounts_column"] = await check_column_exists('promo_groups', 'addon_discounts_enabled') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') @@ -2048,6 +2098,7 @@ async def check_migration_status(): "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", + "promo_groups_addon_discounts_column": "Колонка addon_discounts_enabled у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 917f673f..c8138a39 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -39,6 +39,31 @@ def _format_discount_line(texts, group) -> str: ) +def _format_addon_status_value(texts, enabled: bool) -> str: + key = ( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED_VALUE" + if enabled + else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED_VALUE" + ) + default_text = "включены" if enabled else "отключены" + return texts.t(key, default_text) + + +def _format_addon_discount_line(texts, group) -> str: + enabled = getattr(group, "addon_discounts_enabled", True) + key = ( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED" + if enabled + else "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED" + ) + default_text = ( + "Скидки на докупку доп. услуг: включены" + if enabled + else "Скидки на докупку доп. услуг: отключены" + ) + return texts.t(key, default_text) + + def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: if not raw or not isinstance(raw, dict): return {} @@ -140,6 +165,17 @@ def _parse_period_discounts_input(value: str) -> Dict[int, int]: return discounts +def _parse_boolean_input(value: str) -> bool: + cleaned = (value or "").strip().lower() + + if cleaned in {"1", "true", "yes", "y", "да", "д", "on", "вкл", "+"}: + return True + if cleaned in {"0", "false", "no", "n", "нет", "н", "off", "выкл", "-"}: + return False + + raise ValueError("Invalid boolean input") + + async def _prompt_for_period_discounts( message: types.Message, state: FSMContext, @@ -244,6 +280,27 @@ async def _prompt_for_auto_assign_threshold( await message.answer(prompt_text) +async def _prompt_for_addon_discount_choice( + message: types.Message, + state: FSMContext, + prompt_key: str, + default_text: str, + *, + current_value: Optional[str] = None, +): + data = await state.get_data() + texts = get_texts(data.get("language", "ru")) + prompt_text = texts.t(prompt_key, default_text) + + if current_value is not None: + try: + prompt_text = prompt_text.format(current=current_value) + except KeyError: + pass + + await message.answer(prompt_text) + + def _build_edit_menu_content( texts, group: PromoGroup, @@ -257,6 +314,7 @@ def _build_edit_menu_content( lines = [ header, _format_discount_line(texts, group), + _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), ] @@ -318,6 +376,15 @@ def _build_edit_menu_content( callback_data=f"promo_group_edit_field_{group.id}_periods", ) ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS", + "🛒 Скидки на доп. услуги", + ), + callback_data=f"promo_group_edit_field_{group.id}_addon", + ) + ], [ types.InlineKeyboardButton( text=texts.t( @@ -399,6 +466,7 @@ async def show_promo_groups_menu( group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), + _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", @@ -474,6 +542,7 @@ async def show_promo_group_details( "💳 Промогруппа: {name}", ).format(name=group.name), _format_discount_line(texts, group), + _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", @@ -675,6 +744,39 @@ async def process_create_group_period_discounts( return await state.update_data(new_group_period_discounts=period_discounts) + await state.set_state(AdminStates.creating_promo_group_addon_discount) + + await _prompt_for_addon_discount_choice( + message, + state, + "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT", + "Включать скидки на докупку доп. услуг при действующих скидках? (да/нет)", + ) + + +@admin_required +@error_handler +async def process_create_group_addon_discount( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + addon_enabled = _parse_boolean_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT", + "Введите «да» или «нет».", + ) + ) + return + + await state.update_data(new_group_addon_discounts_enabled=addon_enabled) await state.set_state(AdminStates.creating_promo_group_auto_assign) await _prompt_for_auto_assign_threshold( @@ -716,6 +818,9 @@ async def process_create_group_auto_assign( device_discount_percent=data["new_group_devices"], period_discounts=data.get("new_group_period_discounts"), auto_assign_total_spent_kopeks=auto_assign_kopeks, + addon_discounts_enabled=data.get( + "new_group_addon_discounts_enabled", True + ), ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -826,6 +931,13 @@ async def prompt_edit_promo_group_field( "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT", "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", ).format(current=_format_period_discounts_value(current_discounts)) + elif field == "addon": + await state.set_state(AdminStates.editing_promo_group_addon_discount) + current_value = _format_addon_status_value(texts, getattr(group, "addon_discounts_enabled", True)) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_ADDON_DISCOUNT_PROMPT", + "Включать скидки на докупку доп. услуг? Текущее значение: {current}.", + ).format(current=current_value) elif field == "auto": await state.set_state(AdminStates.editing_promo_group_auto_assign) prompt = texts.t( @@ -1019,6 +1131,50 @@ async def process_edit_group_period_discounts( ) +@admin_required +@error_handler +async def process_edit_group_addon_discount( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + addon_enabled = _parse_boolean_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT", + "Введите «да» или «нет».", + ) + ) + return + + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group( + db, + group, + addon_discounts_enabled=addon_enabled, + ) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( + message, + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), + ) + + @admin_required @error_handler async def process_edit_group_auto_assign( @@ -1235,6 +1391,10 @@ def register_handlers(dp: Dispatcher): process_create_group_period_discounts, AdminStates.creating_promo_group_period_discount, ) + dp.message.register( + process_create_group_addon_discount, + AdminStates.creating_promo_group_addon_discount, + ) dp.message.register( process_create_group_auto_assign, AdminStates.creating_promo_group_auto_assign, @@ -1257,6 +1417,10 @@ def register_handlers(dp: Dispatcher): process_edit_group_period_discounts, AdminStates.editing_promo_group_period_discount, ) + dp.message.register( + process_edit_group_addon_discount, + AdminStates.editing_promo_group_addon_discount, + ) dp.message.register( process_edit_group_auto_assign, AdminStates.editing_promo_group_auto_assign, diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 45fe983f..b84b392d 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -835,6 +835,7 @@ async def show_user_management( • Скидка на сервера: {promo_group.server_discount_percent}% • Скидка на трафик: {promo_group.traffic_discount_percent}% • Скидка на устройства: {promo_group.device_discount_percent}% +• Скидки на доп. услуги при докупке: {"включены" if getattr(promo_group, "addon_discounts_enabled", True) else "отключены"} """ else: text += "\nПромогруппа: Не назначена" @@ -863,21 +864,36 @@ async def _render_user_promo_group( if current_group: current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT.format(name=current_group.name) + addon_status = ( + texts.t("ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED_VALUE", "включены") + if getattr(current_group, "addon_discounts_enabled", True) + else texts.t("ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED_VALUE", "отключены") + ) discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS.format( servers=current_group.server_discount_percent, traffic=current_group.traffic_discount_percent, devices=current_group.device_discount_percent, + addons=addon_status, ) + addon_line = texts.t( + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_LINE", + "Скидки на доп. услуги при докупке: {status}", + ).format(status=addon_status) current_group_id = current_group.id else: current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT_NONE discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE + addon_line = texts.t( + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE", + "Скидки на доп. услуги при докупке: —", + ) current_group_id = None text = ( f"{texts.ADMIN_USER_PROMO_GROUP_TITLE}\n\n" f"{current_line}\n" - f"{discount_line}\n\n" + f"{discount_line}\n" + f"{addon_line}\n\n" f"{texts.ADMIN_USER_PROMO_GROUP_SELECT}" ) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 98b41a5b..6977ac1b 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1,14 +1,99 @@ { + "ACCESS_DENIED": "❌ Access denied", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Insufficient funds\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.", "ADD_COUNTRIES_BUTTON": "🌐 Add countries", - "ADMIN_MAIN_MENU": "🏠 Main menu", "ADMIN_CAMPAIGNS": "📣 Promotional campaigns", + "ADMIN_MAIN_MENU": "🏠 Main menu", + "ADMIN_MESSAGES": "📨 Broadcasts", + "ADMIN_MONITORING": "🔍 Monitoring", + "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", + "ADMIN_PROMOCODES": "🎫 Promo codes", + "ADMIN_PROMO_GROUPS": "💳 Promo groups", + "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", + "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", + "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", + "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", + "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Add-on purchase discounts: disabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Add-on purchase discounts: enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED_VALUE": "disabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED_VALUE": "enabled", + "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", + "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", + "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT": "Enable discounts for add-on purchases when base discounts are set? (yes/no)", + "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:", + "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", + "ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.", + "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete", + "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.", + "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", + "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", + "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", + "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}", + "ADMIN_PROMO_GROUP_EDIT_ADDON_DISCOUNT_PROMPT": "Enable discounts for add-on purchases? Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS": "🛒 Add-on purchase discounts", + "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", + "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Please enter 'yes' or 'no'.", + "ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.", + "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", + "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", + "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", + "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", + "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", + "ADMIN_REFERRALS": "🤝 Referral program", + "ADMIN_REMNAWAVE": "🖥️ Remnawave", + "ADMIN_RULES": "📋 Rules", + "ADMIN_STATISTICS": "📊 Statistics", + "ADMIN_SUBSCRIPTIONS": "📱 Subscriptions", + "ADMIN_USERS": "👥 Users", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_LINE": "Add-on purchase discounts: {status}", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE": "Add-on purchase discounts: —", + "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.", + "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user", + "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group", + "ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}", + "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%, add-ons: {addons}", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.", + "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.", + "ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:", + "ADMIN_USER_PROMO_GROUP_TITLE": "👥 User promo group", + "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”", + "ALREADY_REGISTERED_REFERRAL": "ℹ️ You are already registered. A referral link cannot be applied.", "AUTOPAY_BUTTON": "💳 Auto payment", + "AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!", + "AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically", + "AUTOPAY_FAILED": "\n❌ Autopay failed\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n", "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days", + "AUTOPAY_SUCCESS": "\n✅ Autopay completed\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n", "BACK": "⬅️ Back", + "BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu", "BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription", + "BALANCE_BUTTON": "💰 Balance: {balance}", "BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}", + "BALANCE_BUTTON_ZERO": "💰 Balance: 0 ₽", + "BALANCE_HISTORY": "📊 Transaction history", + "BALANCE_INFO": "\n💰 Balance: {balance}\n\nChoose an action:\n", + "BALANCE_SUPPORT_REQUEST": "🛠️ Request via support", + "BALANCE_TOP_UP": "💳 Top up", + "BUY_SUBSCRIPTION_START": "\n💎 Subscription setup\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n", + "CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!", + "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!", + "CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.", "CANCEL": "❌ Cancel", "CHANGE_DEVICES_BUTTON": "📱 Change devices", + "CHANGE_DEVICES_CONFIRM": "\n📱 Confirm change\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", + "CHANGE_DEVICES_INFO": "\n📱 Adjust device limit\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 Important:\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n", + "CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n", + "CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n", + "CHANGE_DEVICES_TITLE": "📱 Change device limit", "CHANNEL_CHECK_BUTTON": "✅ I have joined", "CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.", "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe", @@ -19,20 +104,17 @@ "CONFIRM": "✅ Confirm", "CONFIRM_CHANGE_BUTTON": "✅ Confirm change", "CONNECT_BUTTON": "🔗 Connect", - "HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ", - "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", - "HAPP_PLATFORM_IOS": "🍎 iOS", - "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", - "HAPP_PLATFORM_WINDOWS": "💻 Windows", - "HAPP_PLATFORM_PC": "💻 PC", - "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", - "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", - "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", + "CONTACT_SUPPORT": "💬 Contact support", "CONTINUE": "➡️ Continue", "CONTINUE_BUTTON": "➡️ Continue", "COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link", + "CREATE_INVITE": "📝 Create invite", "CREATE_INVITE_BUTTON": "📝 Create invite", + "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured", + "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", + "DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})", + "DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}", + "DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed", "DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?", "DEVICE_GUIDE_ANDROID": "🤖 Android", "DEVICE_GUIDE_ANDROID_TV": "📺 Android TV", @@ -42,26 +124,47 @@ "DISABLE_BUTTON": "❌ Disable", "ENABLE_BUTTON": "✅ Enable", "ERROR": "❌ An error occurred", - "ERROR_TRY_AGAIN": "❌ An error occurred. Please try again.", "ERROR_RULES_RETRY": "An error occurred. Please try accepting the rules again:", + "ERROR_TRY_AGAIN": "❌ An error occurred. Please try again.", "GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up", - "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", + "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_PC": "💻 PC", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", "INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.", - "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Insufficient funds\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.", + "INVALID_AMOUNT": "❌ Invalid amount", "LANGUAGE_SELECTED": "🌐 Interface language set: English", "LOADING": "⏳ Loading...", + "MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n", + "MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n", "MAIN_MENU": "👤 {user_name}\n\n📱 Subscription: {subscription_status}\n\nChoose an option:\n", "MAIN_MENU_ACTION_PROMPT": "Choose an option:", "MAIN_MENU_BUTTON": "🏠 Main menu", "MANAGE_DEVICES_BUTTON": "🔧 Manage devices", + "MENU_ADMIN": "⚙️ Admin panel", "MENU_BALANCE": "💰 Balance", + "MENU_BUY_SUBSCRIPTION": "💎 Buy subscription", + "MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription", + "MENU_LANGUAGE": "🌐 Language", + "MENU_PROMOCODE": "🎫 Promo code", + "MENU_REFERRALS": "🤝 Referral program", + "MENU_RULES": "📋 Service rules", + "MENU_SERVER_STATUS": "📊 Server status", "MENU_SUBSCRIPTION": "📱 Subscription", + "MENU_SUPPORT": "🛠️ Support", "MENU_TRIAL": "🎁 Trial subscription", "MY_BALANCE_BUTTON": "💰 My balance", "MY_SUBSCRIPTION_BUTTON": "📱 My subscription", "NO": "❌ No", "NO_SERVERS_AVAILABLE": "❌ No servers available", "NO_TRAFFIC_PACKAGES": "❌ No packages available", + "OPERATION_CANCELLED": "❌ Operation cancelled", "OTHER_APPS_BUTTON": "📋 Other apps", "PAGINATION_NEXT": "➡️", "PAGINATION_PREV": "⬅️", @@ -69,33 +172,215 @@ "PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)", "PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)", "PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)", + "PAYMENT_METHODS_FOOTER": "Choose a top-up method:", + "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Balance top-up methods\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:", + "PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:", + "PAYMENT_METHODS_TITLE": "💳 Balance top-up methods", + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", + "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", + "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", + "PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient", + "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", + "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", + "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", + "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", + "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Bank card", + "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa", + "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Bank card", "PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)", "PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars", "PAYMENT_VIA_SUPPORT": "🛠️ Via support", "PAY_NOW_BUTTON": "💳 Pay", "PAY_WITH_COINS_BUTTON": "🪙 Pay", "PENDING_CANCEL_BUTTON": "⌛ Cancel", + "PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}", + "PERIOD_180_DAYS": "📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}", + "PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}", + "PERIOD_360_DAYS": "📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}", + "PERIOD_60_DAYS": "📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}", + "PERIOD_90_DAYS": "📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}", "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀", + "PROMOCODE_EMPTY_INPUT": "❌ Please enter a valid promo code", + "PROMOCODE_ENTER": "🎫 Enter promo code", + "PROMOCODE_EXPIRED": "❌ Promo code has expired", + "PROMOCODE_INVALID": "❌ Invalid promo code", + "PROMOCODE_SUCCESS": "🎉 Promo code applied!", + "PROMOCODE_USED": "ℹ️ Promo code has already been used", + "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Your promo group discounts", + "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%", + "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%", + "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%", + "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:", + "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", "REFERRAL_ANALYTICS_BUTTON": "📊 Analytics", + "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Earnings by period:", + "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• Month: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• Quarter: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Today: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• Week: {amount}", + "REFERRAL_ANALYTICS_FOOTER": "📈 Keep growing your referral network!", + "REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics", + "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} rewards)", + "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Top {count} referrals:", "REFERRAL_CODE_ACCEPTED": "✅ Referral code accepted!", + "REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.", "REFERRAL_CODE_INVALID": "❌ Invalid referral code", "REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.", "REFERRAL_CODE_QUESTION": "\n🤝 Do you have a friend's referral code?\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n", "REFERRAL_CODE_SKIP": "⏭️ Skip", - "ALREADY_REGISTERED_REFERRAL": "ℹ️ You are already registered. A referral link cannot be applied.", + "REFERRAL_CODE_TITLE": "🆔 Your code: {code}", + "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Earnings by type:", + "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Bonuses for first top-ups: {count} ({amount})", + "REFERRAL_EARNINGS_PURCHASES": "• Purchase commissions: {count} ({amount})", + "REFERRAL_EARNINGS_TOPUPS": "• Top-up commissions: {count} ({amount})", + "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Purchase commission", + "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Top-up commission", + "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up", + "REFERRAL_INFO": "\n🤝 Referral program\n\n👥 Invited: {referrals_count} friends\n💰 Earned: {earned_amount}\n\n🔗 Your referral link:\n{referral_link}\n\n🎫 Your promo code:\n{referral_code}\n\n💰 Terms:\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n", + "REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!", + "REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:", + "REFERRAL_INVITE_CREATED_TITLE": "📝 Invitation created!", + "REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection", + "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection", + "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Servers worldwide", + "REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!", + "REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:", + "REFERRAL_INVITE_MESSAGE": "\n🎯 Invitation to the VPN service\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n", + "REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!", + "REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}", + "REFERRAL_LINK_TITLE": "🔗 Your referral link:", "REFERRAL_LIST_BUTTON": "👥 Referral list", + "REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!", + "REFERRAL_LIST_HEADER": "👥 Your referrals (page {current}/{total})", + "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago", + "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago", + "REFERRAL_LIST_ITEM_EARNED": " 💎 Earned from them: {amount}", + "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", + "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago", + "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Top-ups: {count}", + "REFERRAL_LIST_NEXT_PAGE": "Next ➡️", + "REFERRAL_LIST_PREV_PAGE": "⬅️ Back", + "REFERRAL_PROGRAM_TITLE": "👥 Referral program", + "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Latest rewards:", + "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} from {referral_name}", + "REFERRAL_REWARDS_HEADER": "🎁 How rewards work:", + "REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: {percent}%", + "REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: {bonus}", + "REFERRAL_REWARD_NEW_USER": "• New user receives: {bonus} on the first top-up from {minimum}", + "REFERRAL_SHARE_BUTTON": "📤 Share", + "REFERRAL_STATS_ACTIVE": "• Active referrals: {count}", + "REFERRAL_STATS_CONVERSION": "• Conversion: {rate}%", + "REFERRAL_STATS_FIRST_TOPUPS": "• Made first top-up: {count}", + "REFERRAL_STATS_HEADER": "📊 Your statistics:", + "REFERRAL_STATS_INVITED": "• Invited users: {count}", + "REFERRAL_STATS_MONTH_EARNED": "• Earned last month: {amount}", + "REFERRAL_STATS_TOTAL_EARNED": "• Earned in total: {amount}", + "REGISTRATION_COMPLETING": "✅ Completing registration...", "RESET_ALL_DEVICES_BUTTON": "🔄 Reset all devices", "RESET_DEVICE_CONFIRM_BUTTON": "✅ Reset this device", "RESET_TRAFFIC_BUTTON": "🔄 Reset traffic", - "RULES_HEADER": "📋 Service Rules", + "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout", + "RULES_ACCEPT": "✅ I accept the rules", "RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...", + "RULES_DECLINE": "❌ I do not accept", + "RULES_HEADER": "📋 Service Rules", + "RULES_REQUIRED": "❗️ You must accept the rules to use the service!", "RULES_TEXT_DEFAULT": "📋 Service Usage Rules\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\nBy using the service you agree to follow these rules.", + "SELECT_COUNTRIES": "Select countries:", + "SELECT_DEVICES": "Number of devices:", + "SELECT_PERIOD": "Choose period:", + "SELECT_TRAFFIC": "Choose traffic package:", "SEND_CONTACT_BUTTON": "📱 Share contact", "SEND_LOCATION_BUTTON": "📍 Share location", + "SERVER_STATUS_AVAILABLE": "✅ Online", + "SERVER_STATUS_ERROR_SHORT": "Failed to fetch data", + "SERVER_STATUS_LATENCY": "{latency} ms", + "SERVER_STATUS_LATENCY_UNKNOWN": "no data", + "SERVER_STATUS_NEXT_PAGE": "Next ➡️", + "SERVER_STATUS_NOT_CONFIGURED": "Feature is not available.", + "SERVER_STATUS_NO_SERVERS": "No server data available.", + "SERVER_STATUS_OFFLINE": "no response", + "SERVER_STATUS_PAGINATION": "Page {current} of {total}", + "SERVER_STATUS_PREV_PAGE": "⬅️ Back", + "SERVER_STATUS_REFRESH": "🔄 Refresh", + "SERVER_STATUS_SUMMARY": "Total servers: {total} (online: {online}, offline: {offline})", + "SERVER_STATUS_TITLE": "📊 Server status", + "SERVER_STATUS_UNAVAILABLE": "❌ Offline", + "SERVER_STATUS_UPDATED_AT": "⏱ Updated at: {time}", "SHOW_QR_BUTTON": "📱 Show QR code", "SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link", "SKIP_BUTTON": "Skip ➡️", + "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.", + "STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.", + "STARS_PAYMENT_SUCCESS": "🎉 Payment processed successfully!\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀", + "STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.", + "STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.", + "STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.", + "STARS_PRECHECK_USER_NOT_FOUND": "User not found. Please contact support.", + "SUBSCRIPTION_ACTIVE": "✅ Active", + "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", + "SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:", + "SUBSCRIPTION_APPS_TITLE": "📱 Apps for {device_name}", + "SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Connected devices:\n", + "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", + "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", + "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", + "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app", + "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Connection link:\n{subscription_url}", + "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", + "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", + "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", + "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server", + "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 How to connect:", + "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", + "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Step 2 - Add subscription:", + "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Step 3 - Connect:", + "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", + "SUBSCRIPTION_EXPIRED": "\n❌ Subscription expired\n\nYour subscription has ended. Renew it to restore access.\n", + "SUBSCRIPTION_EXPIRING": "\n⚠️ Subscription expiring!\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n", + "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Subscription expires in {days_text}!\n\nYour paid subscription ends on {end_date}.\n\n💳 Autopay: {autopay_status}\n\n{action_text}\n", + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device", + "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Your import link for the VPN app:\n{subscription_url}", + "SUBSCRIPTION_INFO": "\n📱 Subscription details\n\n📊 Status: {status}\n🎭 Type: {type}\n📅 Valid until: {end_date}\n⏰ Days left: {days_left}\n\n📈 Traffic: {traffic_used} / {traffic_limit}\n🌍 Servers: {countries_count} countries\n📱 Devices: {devices_used} / {devices_limit}\n\n💳 Autopay: {autopay_status}\n", + "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.", + "SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.", + "SUBSCRIPTION_LINK_STEP1": "1. Tap the link above to copy it", + "SUBSCRIPTION_LINK_STEP2": "2. Open your VPN app", + "SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option", + "SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link", + "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", + "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 How to use:", + "SUBSCRIPTION_NONE": "❌ No active subscription", + "SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found", + "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", + "SUBSCRIPTION_NO_SERVERS": "No servers", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", + "SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!", "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings", + "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", + "SUBSCRIPTION_STATUS_ACTIVE": "Active", + "SUBSCRIPTION_STATUS_EXPIRED": "Expired", + "SUBSCRIPTION_STATUS_TRIAL": "Trial", + "SUBSCRIPTION_STATUS_UNKNOWN": "Unknown", + "SUBSCRIPTION_SUMMARY": "\n📋 Final configuration\n\n📅 Period: {period} days\n📈 Traffic: {traffic}\n🌍 Countries: {countries}\n📱 Devices: {devices}\n\n💰 Total: {total_price}\n\nConfirm the purchase?\n", + "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} days", + "SUBSCRIPTION_TIME_LEFT_EXPIRED": "expired", + "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} hr", + "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} min", + "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} GB", + "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (unlimited) | Used: {used} GB", + "SUBSCRIPTION_TRIAL": "🧪 Trial subscription", + "SUBSCRIPTION_TYPE_PAID": "Paid", + "SUBSCRIPTION_TYPE_TRIAL": "Trial", + "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 expires in a few minutes!", + "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ expires today!", + "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ expires tomorrow!", "SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days", "SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)", "SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!", @@ -105,314 +390,38 @@ "SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)", "SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!", "SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!", - "SUBSCRIPTION_ACTIVE": "✅ Active", "SUCCESS": "✅ Success", - "REGISTRATION_COMPLETING": "✅ Completing registration...", - "SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic", - "TOPUP_BALANCE_BUTTON": "💳 Top up balance", - "TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured", - "TRIAL_ACTIVATE_BUTTON": "🎁 Activate", - "PROMOCODE_EMPTY_INPUT": "❌ Please enter a valid promo code", - "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.", - "STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.", - "STARS_PAYMENT_SUCCESS": "🎉 Payment processed successfully!\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀", - "STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.", - "STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.", - "STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.", - "STARS_PRECHECK_USER_NOT_FOUND": "User not found. Please contact support.", - "UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.", - "UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.", - "WELCOME": "\n🎉 Welcome to VPN Service!\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 Advantages:\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n", - "WELCOME_FALLBACK": "Welcome, {user_name}!", - "YES": "✅ Yes", - "ACCESS_DENIED": "❌ Access denied", - "ADMIN_MESSAGES": "📨 Broadcasts", - "ADMIN_MONITORING": "🔍 Monitoring", - "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", - "ADMIN_PROMOCODES": "🎫 Promo codes", - "ADMIN_REFERRALS": "🤝 Referral program", - "ADMIN_REMNAWAVE": "🖥️ Remnawave", - "ADMIN_RULES": "📋 Rules", - "ADMIN_STATISTICS": "📊 Statistics", - "ADMIN_PROMO_GROUPS": "💳 Promo groups", - "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", - "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", - "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", - "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", - "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", - "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group", - "ADMIN_USER_PROMO_GROUP_TITLE": "👥 User promo group", - "ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}", - "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned", - "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", - "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.", - "ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:", - "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”", - "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.", - "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.", - "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user", - "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}", - "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", - "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", - "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", - "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", - "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete", - "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:", - "ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.", - "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", - "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", - "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", - "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", - "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", - "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", - "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", - "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", - "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.", - "ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.", - "ADMIN_SUBSCRIPTIONS": "📱 Subscriptions", - "ADMIN_USERS": "👥 Users", - "AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!", - "AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically", - "AUTOPAY_FAILED": "\n❌ Autopay failed\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n", - "AUTOPAY_SUCCESS": "\n✅ Autopay completed\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n", - "BALANCE_BUTTON": "💰 Balance: {balance}", - "BALANCE_BUTTON_ZERO": "💰 Balance: 0 ₽", - "BALANCE_HISTORY": "📊 Transaction history", - "BALANCE_INFO": "\n💰 Balance: {balance}\n\nChoose an action:\n", - "BALANCE_SUPPORT_REQUEST": "🛠️ Request via support", - "BALANCE_TOP_UP": "💳 Top up", - "CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.", - "CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!", - "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!", - "BUY_SUBSCRIPTION_START": "\n💎 Subscription setup\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n", - "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Your promo group discounts", - "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%", - "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%", - "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%", - "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:", - "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", - "CHANGE_DEVICES_CONFIRM": "\n📱 Confirm change\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", - "CHANGE_DEVICES_INFO": "\n📱 Adjust device limit\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 Important:\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n", - "CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n", - "CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n", - "CHANGE_DEVICES_TITLE": "📱 Change device limit", - "CONTACT_SUPPORT": "💬 Contact support", - "CREATE_INVITE": "📝 Create invite", - "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", - "DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})", - "DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}", - "DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed", - "INVALID_AMOUNT": "❌ Invalid amount", - "MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n", - "MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n", - "MENU_ADMIN": "⚙️ Admin panel", - "MENU_BUY_SUBSCRIPTION": "💎 Buy subscription", - "MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription", - "MENU_PROMOCODE": "🎫 Promo code", - "MENU_REFERRALS": "🤝 Referral program", - "MENU_RULES": "📋 Service rules", - "MENU_SERVER_STATUS": "📊 Server status", - "MENU_SUPPORT": "🛠️ Support", - "OPERATION_CANCELLED": "❌ Operation cancelled", - "PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}", - "PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}", - "PERIOD_60_DAYS": "📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}", - "PERIOD_90_DAYS": "📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}", - "PERIOD_180_DAYS": "📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}", - "PERIOD_360_DAYS": "📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}", - "PROMOCODE_ENTER": "🎫 Enter promo code", - "PROMOCODE_EXPIRED": "❌ Promo code has expired", - "PROMOCODE_INVALID": "❌ Invalid promo code", - "PROMOCODE_SUCCESS": "🎉 Promo code applied!", - "PROMOCODE_USED": "ℹ️ Promo code has already been used", - "REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.", - "REFERRAL_INFO": "\n🤝 Referral program\n\n👥 Invited: {referrals_count} friends\n💰 Earned: {earned_amount}\n\n🔗 Your referral link:\n{referral_link}\n\n🎫 Your promo code:\n{referral_code}\n\n💰 Terms:\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n", - "REFERRAL_INVITE_MESSAGE": "\n🎯 Invitation to the VPN service\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n", - "RULES_ACCEPT": "✅ I accept the rules", - "RULES_DECLINE": "❌ I do not accept", - "RULES_REQUIRED": "❗️ You must accept the rules to use the service!", - "SELECT_COUNTRIES": "Select countries:", - "SELECT_DEVICES": "Number of devices:", - "SELECT_PERIOD": "Choose period:", - "SELECT_TRAFFIC": "Choose traffic package:", - "SUBSCRIPTION_EXPIRED": "\n❌ Subscription expired\n\nYour subscription has ended. Renew it to restore access.\n", - "SUBSCRIPTION_EXPIRING": "\n⚠️ Subscription expiring!\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n", - "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Subscription expires in {days_text}!\n\nYour paid subscription ends on {end_date}.\n\n💳 Autopay: {autopay_status}\n\n{action_text}\n", - "SUBSCRIPTION_INFO": "\n📱 Subscription details\n\n📊 Status: {status}\n🎭 Type: {type}\n📅 Valid until: {end_date}\n⏰ Days left: {days_left}\n\n📈 Traffic: {traffic_used} / {traffic_limit}\n🌍 Servers: {countries_count} countries\n📱 Devices: {devices_used} / {devices_limit}\n\n💳 Autopay: {autopay_status}\n", - "SUBSCRIPTION_NONE": "❌ No active subscription", - "SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found", - "SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!", - "SUBSCRIPTION_SUMMARY": "\n📋 Final configuration\n\n📅 Period: {period} days\n📈 Traffic: {traffic}\n🌍 Countries: {countries}\n📱 Devices: {devices}\n\n💰 Total: {total_price}\n\nConfirm the purchase?\n", - "SUBSCRIPTION_TRIAL": "🧪 Trial subscription", "SUPPORT_INFO": "\n🛠️ Technical support\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n", - "SERVER_STATUS_AVAILABLE": "✅ Online", - "SERVER_STATUS_ERROR_SHORT": "Failed to fetch data", - "SERVER_STATUS_LATENCY": "{latency} ms", - "SERVER_STATUS_LATENCY_UNKNOWN": "no data", - "SERVER_STATUS_NEXT_PAGE": "Next ➡️", - "SERVER_STATUS_NO_SERVERS": "No server data available.", - "SERVER_STATUS_NOT_CONFIGURED": "Feature is not available.", - "SERVER_STATUS_OFFLINE": "no response", - "SERVER_STATUS_PAGINATION": "Page {current} of {total}", - "SERVER_STATUS_PREV_PAGE": "⬅️ Back", - "SERVER_STATUS_REFRESH": "🔄 Refresh", - "SERVER_STATUS_SUMMARY": "Total servers: {total} (online: {online}, offline: {offline})", - "SERVER_STATUS_TITLE": "📊 Server status", - "SERVER_STATUS_UPDATED_AT": "⏱ Updated at: {time}", - "SERVER_STATUS_UNAVAILABLE": "❌ Offline", + "SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic", "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Confirm traffic change\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", "SWITCH_TRAFFIC_INFO": "\n🔄 Switch traffic limit\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 Important:\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n", "SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Traffic limit decreased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\nℹ️ Payments are not refunded\n", "SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Traffic limit increased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\n💰 Charged: {amount}\n", "SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit", + "TOPUP_BALANCE_BUTTON": "💳 Top up balance", "TOP_UP_AMOUNT": "💳 Enter top-up amount (in rubles):", "TOP_UP_METHODS": "\n💳 Select a payment method\n\nAmount: {amount}\n", "TOP_UP_STARS": "⭐ Telegram Stars", "TOP_UP_TRIBUTE": "💎 Bank card", - "TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", + "TRAFFIC_100GB": "📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", "TRAFFIC_10GB": "📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", + "TRAFFIC_250GB": "📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", "TRAFFIC_25GB": "📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}", "TRAFFIC_50GB": "📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}", - "TRAFFIC_100GB": "📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", - "TRAFFIC_250GB": "📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", - "TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", + "TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", "TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", "TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed", + "TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured", + "TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", "TRIAL_ACTIVATED": "🎉 Trial subscription activated!", + "TRIAL_ACTIVATE_BUTTON": "🎁 Activate", "TRIAL_ALREADY_USED": "❌ The trial subscription has already been used", "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic} GB\n📱 Devices: {devices} pcs\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", "TRIAL_ENDING_SOON": "\n🎁 The trial subscription is ending soon!\n\nYour trial expires in a few hours.\n\n💎 Don't want to lose VPN access?\nSwitch to the full subscription!\n\n🔥 Special offer:\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n", + "UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.", + "UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.", "USER_NOT_FOUND": "❌ User not found", - "MENU_LANGUAGE": "🌐 Language", - "SUBSCRIPTION_STATUS_EXPIRED": "Expired", - "SUBSCRIPTION_STATUS_TRIAL": "Trial", - "SUBSCRIPTION_STATUS_ACTIVE": "Active", - "SUBSCRIPTION_STATUS_UNKNOWN": "Unknown", - "SUBSCRIPTION_TIME_LEFT_EXPIRED": "expired", - "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} days", - "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} hr", - "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} min", - "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ expires tomorrow!", - "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ expires today!", - "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 expires in a few minutes!", - "SUBSCRIPTION_TYPE_TRIAL": "Trial", - "SUBSCRIPTION_TYPE_PAID": "Paid", - "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (unlimited) | Used: {used} GB", - "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} GB", - "SUBSCRIPTION_NO_SERVERS": "No servers", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", - "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Connected devices:\n", - "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", - "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Connection link:\n{subscription_url}", - "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app", - "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Your import link for the VPN app:\n{subscription_url}", - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device", - "BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu", - "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured", - "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.", - "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", - "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", - "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", - "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", - "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", - "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", - "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", - "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", - "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", - "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", - "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", - "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Step 2 - Add subscription:", - "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Step 3 - Connect:", - "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 How to connect:", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server", - "SUBSCRIPTION_APPS_TITLE": "📱 Apps for {device_name}", - "SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:", - "SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found", - "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", - "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", - "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 How to use:", - "SUBSCRIPTION_LINK_STEP1": "1. Tap the link above to copy it", - "SUBSCRIPTION_LINK_STEP2": "2. Open your VPN app", - "SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option", - "SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link", - "SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.", - "REFERRAL_PROGRAM_TITLE": "👥 Referral program", - "REFERRAL_STATS_HEADER": "📊 Your statistics:", - "REFERRAL_STATS_INVITED": "• Invited users: {count}", - "REFERRAL_STATS_FIRST_TOPUPS": "• Made first top-up: {count}", - "REFERRAL_STATS_ACTIVE": "• Active referrals: {count}", - "REFERRAL_STATS_CONVERSION": "• Conversion: {rate}%", - "REFERRAL_STATS_TOTAL_EARNED": "• Earned in total: {amount}", - "REFERRAL_STATS_MONTH_EARNED": "• Earned last month: {amount}", - "REFERRAL_REWARDS_HEADER": "🎁 How rewards work:", - "REFERRAL_REWARD_NEW_USER": "• New user receives: {bonus} on the first top-up from {minimum}", - "REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: {bonus}", - "REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: {percent}%", - "REFERRAL_LINK_TITLE": "🔗 Your referral link:", - "REFERRAL_CODE_TITLE": "🆔 Your code: {code}", - "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Latest rewards:", - "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up", - "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Top-up commission", - "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Purchase commission", - "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} from {referral_name}", - "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Earnings by type:", - "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Bonuses for first top-ups: {count} ({amount})", - "REFERRAL_EARNINGS_TOPUPS": "• Top-up commissions: {count} ({amount})", - "REFERRAL_EARNINGS_PURCHASES": "• Purchase commissions: {count} ({amount})", - "REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!", - "REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}", - "REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!", - "REFERRAL_LIST_HEADER": "👥 Your referrals (page {current}/{total})", - "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", - "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Top-ups: {count}", - "REFERRAL_LIST_ITEM_EARNED": " 💎 Earned from them: {amount}", - "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago", - "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago", - "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago", - "REFERRAL_LIST_PREV_PAGE": "⬅️ Back", - "REFERRAL_LIST_NEXT_PAGE": "Next ➡️", - "REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics", - "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Earnings by period:", - "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Today: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• Week: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• Month: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• Quarter: {amount}", - "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Top {count} referrals:", - "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} rewards)", - "REFERRAL_ANALYTICS_FOOTER": "📈 Keep growing your referral network!", - "REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!", - "REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!", - "REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection", - "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Servers worldwide", - "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection", - "REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:", - "REFERRAL_SHARE_BUTTON": "📤 Share", - "REFERRAL_INVITE_CREATED_TITLE": "📝 Invitation created!", - "REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:", - "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Balance top-up methods\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:", - "PAYMENT_METHODS_TITLE": "💳 Balance top-up methods", - "PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:", - "PAYMENT_METHODS_FOOTER": "Choose a top-up method:", - "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", - "PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient", - "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Bank card", - "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa", - "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Bank card", - "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", - "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", - "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", - "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", - "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance." - + "WELCOME": "\n🎉 Welcome to VPN Service!\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 Advantages:\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n", + "WELCOME_FALLBACK": "Welcome, {user_name}!", + "YES": "✅ Yes" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 831a55d1..a099b22a 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1,62 +1,74 @@ { "ACCESS_DENIED": "❌ Доступ запрещен", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Недостаточно средств\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.", "ADD_COUNTRIES_BUTTON": "🌐 Добавить страны", - "ADMIN_MAIN_MENU": "🏠 Главное меню", "ADMIN_CAMPAIGNS": "📣 Рекламные кампании", + "ADMIN_MAIN_MENU": "🏠 Главное меню", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MONITORING": "🔍 Мониторинг", "ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n", "ADMIN_PROMOCODES": "🎫 Промокоды", + "ADMIN_PROMO_GROUPS": "💳 Промогруппы", + "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", + "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", + "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", + "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", + "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Скидки на докупку доп. услуг: отключены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Скидки на докупку доп. услуг: включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED_VALUE": "отключены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED_VALUE": "включены", + "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", + "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", + "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT": "Включать скидки на докупку доп. услуг при действующих скидках? (да/нет)", + "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:", + "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", + "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", + "ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.", + "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить", + "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.", + "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", + "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", + "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", + "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}", + "ADMIN_PROMO_GROUP_EDIT_ADDON_DISCOUNT_PROMPT": "Включать скидки на докупку доп. услуг? Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS": "🛒 Скидки на доп. услуги", + "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", + "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Введите «да» или «нет».", + "ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.", + "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", + "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", + "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", + "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", + "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_REFERRALS": "🤝 Партнерка", "ADMIN_REMNAWAVE": "🖥️ Remnawave", "ADMIN_RULES": "📋 Правила", "ADMIN_STATISTICS": "📊 Статистика", - "ADMIN_PROMO_GROUPS": "💳 Промогруппы", - "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", - "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", - "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", - "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", - "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", + "ADMIN_SUBSCRIPTIONS": "📱 Подписки", + "ADMIN_TICKETS_TITLE_CLOSED": "🎫 Закрытые тикеты поддержки:", + "ADMIN_TICKETS_TITLE_OPEN": "🎫 Открытые тикеты поддержки:", + "ADMIN_USERS": "👥 Пользователи", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_LINE": "Скидки на доп. услуги при докупке: {status}", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE": "Скидки на доп. услуги при докупке: —", + "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.", + "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа", - "ADMIN_USER_PROMO_GROUP_TITLE": "👥 Промогруппа пользователя", "ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}", "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена", - "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%, докупка: {addons}", "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.", - "ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:", - "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»", - "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.", "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.", - "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", - "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}", - "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", - "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", - "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", - "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", - "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить", - "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:", - "ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.", - "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", - "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", - "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", - "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", - "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", - "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", - "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", - "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", - "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", - "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.", - "ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.", - "ADMIN_SUBSCRIPTIONS": "📱 Подписки", - "ADMIN_USERS": "👥 Пользователи", - "ADMIN_TICKETS_TITLE_OPEN": "🎫 Открытые тикеты поддержки:", - "ADMIN_TICKETS_TITLE_CLOSED": "🎫 Закрытые тикеты поддержки:", + "ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:", + "ADMIN_USER_PROMO_GROUP_TITLE": "👥 Промогруппа пользователя", + "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»", + "ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", "AUTOPAY_BUTTON": "💳 Автоплатёж", "AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!", "AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически", @@ -64,6 +76,7 @@ "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни", "AUTOPAY_SUCCESS": "\n✅ Автоплатеж выполнен\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n", "BACK": "⬅️ Назад", + "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню", "BACK_TO_SUBSCRIPTION": "⬅️ К подписке", "BALANCE_BUTTON": "💰 Баланс: {balance}", "BALANCE_BUTTON_DEFAULT": "💰 Баланс: {balance}", @@ -72,16 +85,10 @@ "BALANCE_INFO": "\n💰 Баланс: {balance}\n\nВыберите действие:\n", "BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку", "BALANCE_TOP_UP": "💳 Пополнить", - "CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.", + "BUY_SUBSCRIPTION_START": "\n💎 Настройка подписки\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n", "CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!", "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!", - "BUY_SUBSCRIPTION_START": "\n💎 Настройка подписки\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n", - "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Скидки вашей промогруппы", - "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%", - "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%", - "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%", - "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:", - "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", + "CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.", "CANCEL": "❌ Отмена", "CHANGE_DEVICES_BUTTON": "📱 Изменить устройства", "CHANGE_DEVICES_CONFIRM": "\n 📱 Подтверждение изменения\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ", @@ -99,22 +106,13 @@ "CONFIRM": "✅ Подтвердить", "CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение", "CONNECT_BUTTON": "🔗 Подключиться", - "HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ", - "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", - "HAPP_PLATFORM_IOS": "🍎 iOS", - "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", - "HAPP_PLATFORM_WINDOWS": "💻 Windows", - "HAPP_PLATFORM_PC": "💻 ПК", - "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", - "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", - "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", "CONTACT_SUPPORT": "💬 Написать в поддержку", "CONTINUE": "➡️ Продолжить", "CONTINUE_BUTTON": "✅ Продолжить", "COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки", "CREATE_INVITE": "📝 Создать приглашение", "CREATE_INVITE_BUTTON": "📝 Создать приглашение", + "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена", "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}", "DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})", "DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}", @@ -128,12 +126,20 @@ "DISABLE_BUTTON": "❌ Выключить", "ENABLE_BUTTON": "✅ Включить", "ERROR": "❌ Произошла ошибка", - "ERROR_TRY_AGAIN": "❌ Произошла ошибка. Попробуйте еще раз.", "ERROR_RULES_RETRY": "Произошла ошибка. Попробуйте принять правила еще раз:", + "ERROR_TRY_AGAIN": "❌ Произошла ошибка. Попробуйте еще раз.", "GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса", - "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", + "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_PC": "💻 ПК", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", "INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n Пополните баланс на {amount} и попробуйте снова.\n ", - "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Недостаточно средств\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.", "INVALID_AMOUNT": "❌ Неверная сумма", "LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: Русский", "LOADING": "⏳ Загрузка...", @@ -168,6 +174,21 @@ "PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)", "PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)", "PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)", + "PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:", + "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Способы пополнения баланса\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:", + "PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:", + "PAYMENT_METHODS_TITLE": "💳 Способы пополнения баланса", + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", + "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", + "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", + "PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно", + "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", + "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", + "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", + "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", + "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банковская карта", + "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", + "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банковская карта", "PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)", "PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars", "PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку", @@ -181,26 +202,86 @@ "PERIOD_60_DAYS": "📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}", "PERIOD_90_DAYS": "📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}", "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Подключиться бесплатно 🚀", - "PROMOCODE_ENTER": "🎫 Введите промокод:", "PROMOCODE_EMPTY_INPUT": "❌ Введите корректный промокод", + "PROMOCODE_ENTER": "🎫 Введите промокод:", "PROMOCODE_EXPIRED": "❌ Промокод истек", "PROMOCODE_INVALID": "❌ Неверный промокод", "PROMOCODE_SUCCESS": "🎉 Промокод активирован! {description}", "PROMOCODE_USED": "❌ Промокод уже использован", + "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Скидки вашей промогруппы", + "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%", + "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%", + "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%", + "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:", + "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", "REFERRAL_ANALYTICS_BUTTON": "📊 Аналитика", - "REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.", + "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Доходы по периодам:", + "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• За месяц: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• За квартал: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Сегодня: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• За неделю: {amount}", + "REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!", + "REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов", + "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)", + "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Топ-{count} рефералов:", "REFERRAL_CODE_ACCEPTED": "✅ Реферальный код принят!", + "REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.", "REFERRAL_CODE_INVALID": "❌ Неверный реферальный код", "REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start", "REFERRAL_CODE_QUESTION": "\n🤝 У вас есть реферальный код от друга?\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n", "REFERRAL_CODE_SKIP": "⏭️ Пропустить", - "ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", + "REFERRAL_CODE_TITLE": "🆔 Ваш код: {code}", + "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Доходы по типам:", + "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Бонусы за первые пополнения: {count} ({amount})", + "REFERRAL_EARNINGS_PURCHASES": "• Комиссии с покупок: {count} ({amount})", + "REFERRAL_EARNINGS_TOPUPS": "• Комиссии с пополнений: {count} ({amount})", + "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Комиссия с покупки", + "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Комиссия с пополнения", + "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение", "REFERRAL_INFO": "\n🤝 Реферальная программа\n\n👥 Приглашено: {referrals_count} друзей\n💰 Заработано: {earned_amount}\n\n🔗 Ваша реферальная ссылка:\n{referral_link}\n\n🎫 Ваш промокод:\n{referral_code}\n\n💰 Условия:\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n", + "REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!", + "REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:", + "REFERRAL_INVITE_CREATED_TITLE": "📝 Приглашение создано!", + "REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение", + "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита", + "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру", + "REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!", + "REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:", "REFERRAL_INVITE_MESSAGE": "\n🎯 Приглашение в VPN сервис\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n", + "REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!", + "REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}", + "REFERRAL_LINK_TITLE": "🔗 Ваша реферальная ссылка:", "REFERRAL_LIST_BUTTON": "👥 Список рефералов", + "REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!", + "REFERRAL_LIST_HEADER": "👥 Ваши рефералы (стр. {current}/{total})", + "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад", + "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно", + "REFERRAL_LIST_ITEM_EARNED": " 💎 Заработано с него: {amount}", + "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", + "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад", + "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Пополнений: {count}", + "REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️", + "REFERRAL_LIST_PREV_PAGE": "⬅️ Назад", + "REFERRAL_PROGRAM_TITLE": "👥 Реферальная программа", + "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Последние начисления:", + "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} от {referral_name}", + "REFERRAL_REWARDS_HEADER": "🎁 Как работают награды:", + "REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: {percent}%", + "REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: {bonus}", + "REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: {bonus} при первом пополнении от {minimum}", + "REFERRAL_SHARE_BUTTON": "📤 Поделиться", + "REFERRAL_STATS_ACTIVE": "• Активных рефералов: {count}", + "REFERRAL_STATS_CONVERSION": "• Конверсия: {rate}%", + "REFERRAL_STATS_FIRST_TOPUPS": "• Сделали первое пополнение: {count}", + "REFERRAL_STATS_HEADER": "📊 Ваша статистика:", + "REFERRAL_STATS_INVITED": "• Приглашено пользователей: {count}", + "REFERRAL_STATS_MONTH_EARNED": "• За последний месяц: {amount}", + "REFERRAL_STATS_TOTAL_EARNED": "• Заработано всего: {amount}", + "REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...", "RESET_ALL_DEVICES_BUTTON": "🔄 Сбросить все устройства", "RESET_DEVICE_CONFIRM_BUTTON": "✅ Да, сбросить это устройство", "RESET_TRAFFIC_BUTTON": "🔄 Сбросить трафик", + "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки", "RULES_ACCEPT": "✅ Принимаю правила", "RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...", "RULES_DECLINE": "❌ Не принимаю", @@ -213,20 +294,95 @@ "SELECT_TRAFFIC": "Выберите пакет трафика:", "SEND_CONTACT_BUTTON": "📱 Отправить контакт", "SEND_LOCATION_BUTTON": "📍 Отправить геолокацию", + "SERVER_STATUS_AVAILABLE": "✅ Доступны", + "SERVER_STATUS_ERROR_SHORT": "Не удалось получить данные", + "SERVER_STATUS_LATENCY": "{latency} мс", + "SERVER_STATUS_LATENCY_UNKNOWN": "нет данных", + "SERVER_STATUS_NEXT_PAGE": "Вперед ➡️", + "SERVER_STATUS_NOT_CONFIGURED": "Функция недоступна.", + "SERVER_STATUS_NO_SERVERS": "Нет данных о серверах.", + "SERVER_STATUS_OFFLINE": "нет ответа", + "SERVER_STATUS_PAGINATION": "Страница {current} из {total}", + "SERVER_STATUS_PREV_PAGE": "⬅️ Назад", + "SERVER_STATUS_REFRESH": "🔄 Обновить", + "SERVER_STATUS_SUMMARY": "Всего серверов: {total} (в сети: {online}, вне сети: {offline})", + "SERVER_STATUS_TITLE": "📊 Статус серверов", + "SERVER_STATUS_UNAVAILABLE": "❌ Недоступны", + "SERVER_STATUS_UPDATED_AT": "⏱ Обновлено: {time}", "SHOW_QR_BUTTON": "📱 Показать QR код", "SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки", "SKIP_BUTTON": "⏭️ Пропустить", + "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.", + "STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.", + "STARS_PAYMENT_SUCCESS": "🎉 Платеж успешно обработан!\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀", + "STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.", + "STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.", + "STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.", + "STARS_PRECHECK_USER_NOT_FOUND": "Пользователь не найден. Обратитесь в поддержку.", "SUBSCRIPTION_ACTIVE": "✅ Активна", + "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", + "SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:", + "SUBSCRIPTION_APPS_TITLE": "📱 Приложения для {device_name}", + "SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Подключенные устройства:\n", + "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", + "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", + "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", + "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", + "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Ссылка для подключения:\n{subscription_url}", + "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", + "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", + "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", + "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу", + "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 Как подключить:", + "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", + "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Шаг 2 - Добавление подписки:", + "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Шаг 3 - Подключение:", + "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", "SUBSCRIPTION_EXPIRED": "\n❌ Подписка истекла\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n", "SUBSCRIPTION_EXPIRING": "\n⚠️ Подписка истекает!\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n", "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Подписка истекает через {days_text}!\n\nВаша платная подписка истекает {end_date}.\n\n💳 Автоплатеж: {autopay_status}\n\n{action_text}\n", + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", "SUBSCRIPTION_INFO": "\n📱 Информация о подписке\n\n📊 Статус: {status}\n🎭 Тип: {type}\n📅 Действует до: {end_date}\n⏰ Осталось дней: {days_left}\n\n📈 Трафик: {traffic_used} / {traffic_limit}\n🌍 Серверы: {countries_count} стран\n📱 Устройства: {devices_used} / {devices_limit}\n\n💳 Автоплатеж: {autopay_status}\n", + "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", + "SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", + "SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать", + "SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение", + "SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"", + "SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку", + "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", + "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 Как использовать:", "SUBSCRIPTION_NONE": "❌ Нет активной подписки", "SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена", + "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", + "SUBSCRIPTION_NO_SERVERS": "Нет серверов", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", "SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!", "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки", + "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", + "SUBSCRIPTION_STATUS_ACTIVE": "Активна", + "SUBSCRIPTION_STATUS_EXPIRED": "Истекла", + "SUBSCRIPTION_STATUS_TRIAL": "Тестовая", + "SUBSCRIPTION_STATUS_UNKNOWN": "Неизвестно", "SUBSCRIPTION_SUMMARY": "\n📋 Итоговая конфигурация\n\n📅 Период: {period} дней\n📈 Трафик: {traffic}\n🌍 Страны: {countries}\n📱 Устройства: {devices}\n\n💰 Итого к оплате: {total_price}\n\nПодтвердить покупку?\n", + "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} дн.", + "SUBSCRIPTION_TIME_LEFT_EXPIRED": "истёк", + "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} ч.", + "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} мин.", + "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} ГБ", + "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (безлимит) | Использовано: {used} ГБ", "SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка", + "SUBSCRIPTION_TYPE_PAID": "Платная", + "SUBSCRIPTION_TYPE_TRIAL": "Триал", + "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 истекает через несколько минут!", + "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ истекает сегодня!", + "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ истекает завтра!", "SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Активна\n⚠️ истекает через {days} дн.", "SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)", "SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!", @@ -237,23 +393,7 @@ "SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!", "SUB_STATUS_TRIAL_TOMORROW": "🎁 Тестовая подписка\n⚠️ истекает завтра!", "SUCCESS": "✅ Успешно", - "REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...", "SUPPORT_INFO": "\n🛠️ Техническая поддержка\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n", - "SERVER_STATUS_AVAILABLE": "✅ Доступны", - "SERVER_STATUS_ERROR_SHORT": "Не удалось получить данные", - "SERVER_STATUS_LATENCY": "{latency} мс", - "SERVER_STATUS_LATENCY_UNKNOWN": "нет данных", - "SERVER_STATUS_NEXT_PAGE": "Вперед ➡️", - "SERVER_STATUS_NO_SERVERS": "Нет данных о серверах.", - "SERVER_STATUS_NOT_CONFIGURED": "Функция недоступна.", - "SERVER_STATUS_OFFLINE": "нет ответа", - "SERVER_STATUS_PAGINATION": "Страница {current} из {total}", - "SERVER_STATUS_PREV_PAGE": "⬅️ Назад", - "SERVER_STATUS_REFRESH": "🔄 Обновить", - "SERVER_STATUS_SUMMARY": "Всего серверов: {total} (в сети: {online}, вне сети: {offline})", - "SERVER_STATUS_TITLE": "📊 Статус серверов", - "SERVER_STATUS_UPDATED_AT": "⏱ Обновлено: {time}", - "SERVER_STATUS_UNAVAILABLE": "❌ Недоступны", "SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик", "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Подтверждение переключения трафика\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n", "SWITCH_TRAFFIC_INFO": "\n🔄 Переключение лимита трафика\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 Важно:\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n", @@ -264,13 +404,6 @@ "TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):", "TOP_UP_METHODS": "\n💳 Выберите способ оплаты\n\nСумма: {amount}\n", "TOP_UP_STARS": "⭐ Telegram Stars", - "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.", - "STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.", - "STARS_PAYMENT_SUCCESS": "🎉 Платеж успешно обработан!\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀", - "STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.", - "STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.", - "STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.", - "STARS_PRECHECK_USER_NOT_FOUND": "Пользователь не найден. Обратитесь в поддержку.", "TOP_UP_TRIBUTE": "💎 Банковская карта", "TRAFFIC_100GB": "📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", "TRAFFIC_10GB": "📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", @@ -292,129 +425,5 @@ "USER_NOT_FOUND": "❌ Пользователь не найден", "WELCOME": "\n🎉 Добро пожаловать в VPN сервис!\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 Преимущества:\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n", "WELCOME_FALLBACK": "Добро пожаловать, {user_name}!", - "YES": "✅ Да", - "SUBSCRIPTION_STATUS_EXPIRED": "Истекла", - "SUBSCRIPTION_STATUS_TRIAL": "Тестовая", - "SUBSCRIPTION_STATUS_ACTIVE": "Активна", - "SUBSCRIPTION_STATUS_UNKNOWN": "Неизвестно", - "SUBSCRIPTION_TIME_LEFT_EXPIRED": "истёк", - "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} дн.", - "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} ч.", - "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} мин.", - "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ истекает завтра!", - "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ истекает сегодня!", - "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 истекает через несколько минут!", - "SUBSCRIPTION_TYPE_TRIAL": "Триал", - "SUBSCRIPTION_TYPE_PAID": "Платная", - "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (безлимит) | Использовано: {used} ГБ", - "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} ГБ", - "SUBSCRIPTION_NO_SERVERS": "Нет серверов", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", - "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Подключенные устройства:\n", - "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", - "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Ссылка для подключения:\n{subscription_url}", - "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", - "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню", - "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена", - "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", - "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", - "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", - "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", - "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", - "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", - "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", - "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", - "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", - "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", - "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", - "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", - "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Шаг 2 - Добавление подписки:", - "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Шаг 3 - Подключение:", - "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 Как подключить:", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу", - "SUBSCRIPTION_APPS_TITLE": "📱 Приложения для {device_name}", - "SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:", - "SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено", - "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", - "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", - "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 Как использовать:", - "SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать", - "SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение", - "SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"", - "SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку", - "SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", - "REFERRAL_PROGRAM_TITLE": "👥 Реферальная программа", - "REFERRAL_STATS_HEADER": "📊 Ваша статистика:", - "REFERRAL_STATS_INVITED": "• Приглашено пользователей: {count}", - "REFERRAL_STATS_FIRST_TOPUPS": "• Сделали первое пополнение: {count}", - "REFERRAL_STATS_ACTIVE": "• Активных рефералов: {count}", - "REFERRAL_STATS_CONVERSION": "• Конверсия: {rate}%", - "REFERRAL_STATS_TOTAL_EARNED": "• Заработано всего: {amount}", - "REFERRAL_STATS_MONTH_EARNED": "• За последний месяц: {amount}", - "REFERRAL_REWARDS_HEADER": "🎁 Как работают награды:", - "REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: {bonus} при первом пополнении от {minimum}", - "REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: {bonus}", - "REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: {percent}%", - "REFERRAL_LINK_TITLE": "🔗 Ваша реферальная ссылка:", - "REFERRAL_CODE_TITLE": "🆔 Ваш код: {code}", - "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Последние начисления:", - "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение", - "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Комиссия с пополнения", - "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Комиссия с покупки", - "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} от {referral_name}", - "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Доходы по типам:", - "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Бонусы за первые пополнения: {count} ({amount})", - "REFERRAL_EARNINGS_TOPUPS": "• Комиссии с пополнений: {count} ({amount})", - "REFERRAL_EARNINGS_PURCHASES": "• Комиссии с покупок: {count} ({amount})", - "REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!", - "REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}", - "REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!", - "REFERRAL_LIST_HEADER": "👥 Ваши рефералы (стр. {current}/{total})", - "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", - "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Пополнений: {count}", - "REFERRAL_LIST_ITEM_EARNED": " 💎 Заработано с него: {amount}", - "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад", - "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад", - "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно", - "REFERRAL_LIST_PREV_PAGE": "⬅️ Назад", - "REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️", - "REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов", - "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Доходы по периодам:", - "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Сегодня: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• За неделю: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• За месяц: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• За квартал: {amount}", - "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Топ-{count} рефералов:", - "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)", - "REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!", - "REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!", - "REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!", - "REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение", - "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру", - "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита", - "REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:", - "REFERRAL_SHARE_BUTTON": "📤 Поделиться", - "REFERRAL_INVITE_CREATED_TITLE": "📝 Приглашение создано!", - "REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:", - "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Способы пополнения баланса\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:", - "PAYMENT_METHODS_TITLE": "💳 Способы пополнения баланса", - "PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:", - "PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:", - "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", - "PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно", - "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банковская карта", - "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", - "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банковская карта", - "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", - "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", - "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", - "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", - "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку." - + "YES": "✅ Да" } diff --git a/app/states.py b/app/states.py index f824f9a5..6bf45cf3 100644 --- a/app/states.py +++ b/app/states.py @@ -69,6 +69,7 @@ class AdminStates(StatesGroup): creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() creating_promo_group_period_discount = State() + creating_promo_group_addon_discount = State() creating_promo_group_auto_assign = State() editing_promo_group_menu = State() @@ -77,6 +78,7 @@ class AdminStates(StatesGroup): editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() editing_promo_group_period_discount = State() + editing_promo_group_addon_discount = State() editing_promo_group_auto_assign = State() editing_squad_price = State() diff --git a/locales/en.json b/locales/en.json index f217ed28..7b9d8a81 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,525 +1,534 @@ { - "ADD_COUNTRIES_BUTTON": "🌐 Add countries", - "ADMIN_MAIN_MENU": "🏠 Main menu", - "ADMIN_CAMPAIGNS": "📣 Promotional campaigns", - "ADMIN_REPORTS": "📊 Reports", - "AUTOPAY_BUTTON": "💳 Auto payment", - "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days", - "BACK": "⬅️ Back", - "BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription", - "BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}", - "CANCEL": "❌ Cancel", - "CHANGE_DEVICES_BUTTON": "📱 Change devices", - "CHANNEL_CHECK_BUTTON": "✅ I have joined", - "CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.", - "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe", - "CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!", - "CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing", - "CHECK_STATUS_BUTTON": "📊 Check status", - "CHOOSE_ANOTHER_DEVICE": "📱 Choose another device", - "CONFIRM": "✅ Confirm", - "CONFIRM_CHANGE_BUTTON": "✅ Confirm change", - "CONNECT_BUTTON": "🔗 Connect", - "HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ", - "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", - "HAPP_PLATFORM_IOS": "🍎 iOS", - "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", - "HAPP_PLATFORM_WINDOWS": "💻 Windows", - "HAPP_PLATFORM_PC": "💻 PC", - "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", - "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", - "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", - "CONTINUE": "➡️ Continue", - "CONTINUE_BUTTON": "➡️ Continue", - "COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link", - "CREATE_INVITE_BUTTON": "📝 Create invite", - "DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?", - "DEVICE_GUIDE_ANDROID": "🤖 Android", - "DEVICE_GUIDE_ANDROID_TV": "📺 Android TV", - "DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)", - "DEVICE_GUIDE_MAC": "🎯 macOS", - "DEVICE_GUIDE_WINDOWS": "💻 Windows", - "DISABLE_BUTTON": "❌ Disable", - "ENABLE_BUTTON": "✅ Enable", - "ERROR": "❌ An error occurred", - "ERROR_TRY_AGAIN": "❌ An error occurred. Please try again.", - "ERROR_RULES_RETRY": "An error occurred. Please try accepting the rules again:", - "GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up", - "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout", - "INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.", - "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Insufficient funds\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.", - "LANGUAGE_SELECTED": "🌐 Interface language set: English", - "LOADING": "⏳ Loading...", - "MAIN_MENU": "👤 {user_name}\n\n📱 Subscription: {subscription_status}\n\nChoose an option:\n", - "MAIN_MENU_ACTION_PROMPT": "Choose an option:", - "MAIN_MENU_BUTTON": "🏠 Main menu", - "MANAGE_DEVICES_BUTTON": "🔧 Manage devices", - "MENU_BALANCE": "💰 Balance", - "MENU_SUBSCRIPTION": "📱 Subscription", - "MENU_TRIAL": "🎁 Trial subscription", - "MY_BALANCE_BUTTON": "💰 My balance", - "MY_SUBSCRIPTION_BUTTON": "📱 My subscription", - "NO": "❌ No", - "NO_SERVERS_AVAILABLE": "❌ No servers available", - "NO_TRAFFIC_PACKAGES": "❌ No packages available", - "OTHER_APPS_BUTTON": "📋 Other apps", - "PAGINATION_NEXT": "➡️", - "PAGINATION_PREV": "⬅️", - "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable", - "PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)", - "PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)", - "PAYMENT_CARD_PAL24": "💳 Bank card (PayPalych)", - "PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)", - "PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)", - "PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)", - "PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars", - "PAYMENT_VIA_SUPPORT": "🛠️ Via support", - "PAY_NOW_BUTTON": "💳 Pay", - "PAY_WITH_COINS_BUTTON": "🪙 Pay", - "MULENPAY_TOPUP_PROMPT": "💳 Mulen Pay payment\n\nEnter an amount between 100 and 100,000 ₽.\nThe payment is processed by the secure Mulen Pay platform.", - "MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.", - "MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay", - "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Mulen Pay payment\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 How to pay:\n1. Press ‘Pay with Mulen Pay’\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", - "PAL24_TOPUP_PROMPT": "💳 PayPalych payment\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed by the secure PayPalych platform.", - "PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.", - "PAL24_PAY_BUTTON": "💳 Pay with PayPalych", - "PAL24_PAYMENT_INSTRUCTIONS": "💳 PayPalych payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\n1. Press ‘Pay with PayPalych’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", - "PENDING_CANCEL_BUTTON": "⌛ Cancel", - "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀", - "REFERRAL_ANALYTICS_BUTTON": "📊 Analytics", - "REFERRAL_CODE_ACCEPTED": "✅ Referral code accepted!", - "REFERRAL_CODE_INVALID": "❌ Invalid referral code", - "REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.", - "REFERRAL_CODE_QUESTION": "\n🤝 Do you have a friend's referral code?\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n", - "REFERRAL_CODE_SKIP": "⏭️ Skip", - "ALREADY_REGISTERED_REFERRAL": "ℹ️ You are already registered. A referral link cannot be applied.", - "REFERRAL_LIST_BUTTON": "👥 Referral list", - "RESET_ALL_DEVICES_BUTTON": "🔄 Reset all devices", - "RESET_DEVICE_CONFIRM_BUTTON": "✅ Reset this device", - "RESET_TRAFFIC_BUTTON": "🔄 Reset traffic", - "RULES_HEADER": "📋 Service Rules", - "RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...", - "RULES_TEXT_DEFAULT": "📋 Service Usage Rules\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\nBy using the service you agree to follow these rules.", - "SEND_CONTACT_BUTTON": "📱 Share contact", - "SEND_LOCATION_BUTTON": "📍 Share location", - "SHOW_QR_BUTTON": "📱 Show QR code", - "SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link", - "SKIP_BUTTON": "Skip ➡️", - "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings", - "SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days", - "SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)", - "SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!", - "SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!", - "SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}", - "SUB_STATUS_NONE": "❌ Not available", - "SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)", - "SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!", - "SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!", - "SUBSCRIPTION_ACTIVE": "✅ Active", - "SUBSCRIPTION_EXTEND": "💎 Extend subscription", - "SUCCESS": "✅ Success", - "REGISTRATION_COMPLETING": "✅ Completing registration...", - "SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic", - "TOPUP_BALANCE_BUTTON": "💳 Top up balance", - "TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured", - "TRIAL_ACTIVATE_BUTTON": "🎁 Activate", - "PROMOCODE_EMPTY_INPUT": "❌ Please enter a valid promo code", - "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.", - "STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.", - "STARS_PAYMENT_SUCCESS": "🎉 Payment processed successfully!\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀", - "STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.", - "STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.", - "STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.", - "STARS_PRECHECK_USER_NOT_FOUND": "User not found. Please contact support.", - "UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.", - "UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.", - "WELCOME": "\n🎉 Welcome to VPN Service!\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 Advantages:\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n", - "WELCOME_FALLBACK": "Welcome, {user_name}!", - "YES": "✅ Yes", - "ACCESS_DENIED": "❌ Access denied", - "ADMIN_MESSAGES": "📨 Broadcasts", - "ADMIN_MONITORING": "🔍 Monitoring", - "ADMIN_MONITORING_SETTINGS": "⚙️ Monitoring settings", - "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", - "ADMIN_PROMOCODES": "🎫 Promo codes", - "ADMIN_REFERRALS": "🤝 Referral program", - "ADMIN_REMNAWAVE": "🖥️ Remnawave", - "ADMIN_RULES": "📋 Rules", - "ADMIN_STATISTICS": "📊 Statistics", - "ADMIN_PROMO_GROUPS": "💳 Promo groups", - "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", - "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", - "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", - "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", - "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", - "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", - "CREATE_TICKET_BUTTON": "🎫 Create ticket", - "MY_TICKETS_BUTTON": "📋 My tickets", - "CONTACT_SUPPORT_BUTTON": "💬 Contact support", - "SUPPORT_BUTTON": "🆘 Support", - "TICKET_PRIORITY_SELECT": "Select ticket priority:", - "TICKET_PRIORITY_LOW": "🟢 Low", - "TICKET_PRIORITY_NORMAL": "🟡 Normal", - "TICKET_PRIORITY_HIGH": "🟠 High", - "TICKET_PRIORITY_URGENT": "🔴 Urgent", - "CANCEL_TICKET_CREATION": "❌ Cancel ticket creation", - "TICKET_TITLE_INPUT": "Enter ticket title:", - "TICKET_TITLE_TOO_SHORT": "Title must contain at least 5 characters. Try again:", - "TICKET_TITLE_TOO_LONG": "Title is too long. Maximum 255 characters. Try again:", - "TICKET_MESSAGE_INPUT": "Now describe your problem or question:", - "TICKET_MESSAGE_TOO_SHORT": "Message must contain at least 10 characters. Try again:", - "TICKET_CREATED_SUCCESS": "✅ Ticket #{ticket_id} created successfully!\n\nTitle: {title}\n\nWe will respond to you soon.", - "VIEW_TICKET": "👁️ View ticket", - "BACK_TO_MENU": "🏠 Back to menu", - "TICKET_CREATION_ERROR": "❌ An error occurred while creating the ticket. Please try again later.", - "NO_TICKETS": "You don't have any tickets yet.", - "MY_TICKETS_TITLE": "📋 Your tickets:", - "TICKET_STATUS_OPEN": "Open", - "TICKET_STATUS_ANSWERED": "Answered", - "TICKET_STATUS_CLOSED": "Closed", - "TICKET_STATUS_PENDING": "Pending", - "REPLY_TO_TICKET": "💬 Reply", - "CLOSE_TICKET": "🔒 Close ticket", - "CANCEL_REPLY": "❌ Cancel reply", - "TICKET_REPLY_INPUT": "Enter your reply:", - "TICKET_REPLY_TOO_SHORT": "Reply must contain at least 5 characters. Try again:", - "TICKET_REPLY_SENT": "✅ Your reply has been sent!", - "TICKET_REPLY_ERROR": "❌ An error occurred while sending the reply. Please try again later.", - "TICKET_CLOSED": "✅ Ticket closed.", - "TICKET_CLOSE_ERROR": "❌ Error closing ticket.", - "TICKET_NOT_FOUND": "Ticket not found.", - "TICKET_CREATION_CANCELLED": "Ticket creation cancelled.", - "BACK_TO_SUPPORT": "⬅️ Back to support", - "TICKET_REPLY_CANCELLED": "Reply cancelled.", - "BACK_TO_TICKETS": "⬅️ Back to tickets", - "NO_TICKETS_ADMIN": "No tickets to display.", - "ADMIN_TICKETS_TITLE": "🎫 All support tickets:", - "ADMIN_TICKET_REPLY_INPUT": "Enter support reply:", - - "ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!", - "TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.", - "TICKET_UPDATE_ERROR": "❌ Error updating ticket.", - "MARK_AS_ANSWERED": "✅ Mark as answered", - "TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:", - "CLOSE_NOTIFICATION": "❌ Close notification", - "REPORT_CLOSE": "❌ Close", - "REPORT_CLOSED": "✅ Report closed.", - "REPORT_CLOSE_ERROR": "❌ Failed to close the report.", - "NOTIFICATION_CLOSED": "Notification closed.", - "UNBLOCK": "✅ Unblock", - "BLOCK_FOREVER": "🚫 Block permanently", - "BLOCK_BY_TIME": "⏳ Temporary block", - "ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):", - "TICKET_ATTACHMENTS": "📎 Attachments", - "OPEN_TICKETS": "🔴 Open", - "CLOSED_TICKETS": "🟢 Closed", - "OPEN_TICKETS_HEADER": "🔴 Open tickets", - "CLOSED_TICKETS_HEADER": "🟢 Closed tickets", - "SENDING_ATTACHMENTS": "📎 Sending attachments...", - "NO_ATTACHMENTS": "No attachments.", - "ATTACHMENTS_SENT": "✅ Attachments sent.", - "DELETE_MESSAGE": "🗑 Delete", - "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group", - "ADMIN_USER_PROMO_GROUP_TITLE": "👥 User promo group", - "ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}", - "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned", - "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", - "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.", - "ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:", - "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”", - "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.", - "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.", - "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user", - "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}", - "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", - "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", - "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", - "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", - "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete", - "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:", - "ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.", - "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", - "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", - "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", - "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", - "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", - "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100). Current value: {current}.", - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100). Current value: {current}.", - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100). Current value: {current}.", - "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", - "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽", - "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”", - "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:", - "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename", - "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount", - "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", - "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", - "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", - "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", - "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.", - "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", - "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", - "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", - "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.", - "ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.", - "ADMIN_SUBSCRIPTIONS": "📱 Subscriptions", - "ADMIN_USERS": "👥 Users", - "AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!", - "AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically", - "AUTOPAY_FAILED": "\n❌ Autopay failed\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n", - "AUTOPAY_SUCCESS": "\n✅ Autopay completed\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n", - "BALANCE_BUTTON": "💰 Balance: {balance}", - "BALANCE_BUTTON_ZERO": "💰 Balance: 0 ₽", - "BALANCE_HISTORY": "📊 Transaction history", - "BALANCE_INFO": "\n💰 Balance: {balance}\n\nChoose an action:\n", - "BALANCE_SUPPORT_REQUEST": "🛠️ Request via support", - "BALANCE_TOP_UP": "💳 Top up", - "BALANCE_TOPUP": "💳 Top up balance", - "CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.", - "CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!", - "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!", - "BUY_SUBSCRIPTION_START": "\n💎 Subscription setup\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n", - "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Your promo group discounts", - "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%", - "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%", - "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%", - "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:", - "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", - "CHANGE_DEVICES_CONFIRM": "\n📱 Confirm change\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", - "CHANGE_DEVICES_INFO": "\n📱 Adjust device limit\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 Important:\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n", - "CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n", - "CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n", - "CHANGE_DEVICES_TITLE": "📱 Change device limit", - "CONTACT_SUPPORT": "💬 Contact support", - "CREATE_INVITE": "📝 Create invite", - "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", - "DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})", - "DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}", - "DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed", - "INVALID_AMOUNT": "❌ Invalid amount", - "MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n", - "MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n", - "MENU_ADMIN": "⚙️ Admin panel", - "MENU_BUY_SUBSCRIPTION": "💎 Buy subscription", - "MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription", - "MENU_PROMOCODE": "🎫 Promo code", - "MENU_REFERRALS": "🤝 Referral program", - "MENU_RULES": "📋 Service rules", - "MENU_SUPPORT": "🛠️ Support", - "OPERATION_CANCELLED": "❌ Operation cancelled", - "PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}", - "PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}", - "PERIOD_60_DAYS": "📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}", - "PERIOD_90_DAYS": "📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}", - "PERIOD_180_DAYS": "📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}", - "PERIOD_360_DAYS": "📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}", - "PROMOCODE_ENTER": "🎫 Enter promo code", - "PROMOCODE_EXPIRED": "❌ Promo code has expired", - "PROMOCODE_INVALID": "❌ Invalid promo code", - "PROMOCODE_SUCCESS": "🎉 Promo code applied!", - "PROMOCODE_USED": "ℹ️ Promo code has already been used", - "REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.", - "REFERRAL_INFO": "\n🤝 Referral program\n\n👥 Invited: {referrals_count} friends\n💰 Earned: {earned_amount}\n\n🔗 Your referral link:\n{referral_link}\n\n🎫 Your promo code:\n{referral_code}\n\n💰 Terms:\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n", - "REFERRAL_INVITE_MESSAGE": "\n🎯 Invitation to the VPN service\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n", - "RULES_ACCEPT": "✅ I accept the rules", - "RULES_DECLINE": "❌ I do not accept", - "RULES_REQUIRED": "❗️ You must accept the rules to use the service!", - "SELECT_COUNTRIES": "Select countries:", - "SELECT_DEVICES": "Number of devices:", - "SELECT_PERIOD": "Choose period:", - "SELECT_TRAFFIC": "Choose traffic package:", - "SUBSCRIPTION_EXPIRED": "\n❌ Subscription expired\n\nYour subscription has ended. Renew it to restore access.\n", - "SUBSCRIPTION_EXPIRING": "\n⚠️ Subscription expiring!\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n", - "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Subscription expires in {days_text}!\n\nYour paid subscription ends on {end_date}.\n\n💳 Autopay: {autopay_status}\n\n{action_text}\n", - "SUBSCRIPTION_INFO": "\n📱 Subscription details\n\n📊 Status: {status}\n🎭 Type: {type}\n📅 Valid until: {end_date}\n⏰ Days left: {days_left}\n\n📈 Traffic: {traffic_used} / {traffic_limit}\n🌍 Servers: {countries_count} countries\n📱 Devices: {devices_used} / {devices_limit}\n\n💳 Autopay: {autopay_status}\n", - "SUBSCRIPTION_NONE": "❌ No active subscription", - "SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found", - "SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!", - "SUBSCRIPTION_SUMMARY": "\n📋 Final configuration\n\n📅 Period: {period} days\n📈 Traffic: {traffic}\n🌍 Countries: {countries}\n📱 Devices: {devices}\n\n💰 Total: {total_price}\n\nConfirm the purchase?\n", - "SUBSCRIPTION_TRIAL": "🧪 Trial subscription", - "SUPPORT_INFO": "\n🛠️ Technical support\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n", - "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Confirm traffic change\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", - "SWITCH_TRAFFIC_INFO": "\n🔄 Switch traffic limit\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 Important:\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n", - "SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Traffic limit decreased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\nℹ️ Payments are not refunded\n", - "SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Traffic limit increased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\n💰 Charged: {amount}\n", - "SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit", - "TOP_UP_AMOUNT": "💳 Enter top-up amount (in rubles):", - "TOP_UP_METHODS": "\n💳 Select a payment method\n\nAmount: {amount}\n", - "TOP_UP_STARS": "⭐ Telegram Stars", - "TOP_UP_TRIBUTE": "💎 Bank card", - "TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", - "TRAFFIC_10GB": "📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", - "TRAFFIC_25GB": "📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}", - "TRAFFIC_50GB": "📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}", - "TRAFFIC_100GB": "📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", - "TRAFFIC_250GB": "📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", - "TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", - "TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", - "TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed", - "TRIAL_ACTIVATED": "🎉 Trial subscription activated!", - "TRIAL_ALREADY_USED": "❌ The trial subscription has already been used", - "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic} GB\n📱 Devices: {devices} pcs\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", - "TRIAL_ENDING_SOON": "\n🎁 The trial subscription is ending soon!\n\nYour trial expires in a few hours.\n\n💎 Don't want to lose VPN access?\nSwitch to the full subscription!\n\n🔥 Special offer:\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n", - "USER_NOT_FOUND": "❌ User not found", - "MENU_LANGUAGE": "🌐 Language", - "SUBSCRIPTION_STATUS_EXPIRED": "Expired", - "SUBSCRIPTION_STATUS_TRIAL": "Trial", - "SUBSCRIPTION_STATUS_ACTIVE": "Active", - "SUBSCRIPTION_STATUS_UNKNOWN": "Unknown", - "SUBSCRIPTION_TIME_LEFT_EXPIRED": "expired", - "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} days", - "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} hr", - "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} min", - "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ expires tomorrow!", - "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ expires today!", - "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 expires in a few minutes!", - "SUBSCRIPTION_TYPE_TRIAL": "Trial", - "SUBSCRIPTION_TYPE_PAID": "Paid", - "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (unlimited) | Used: {used} GB", - "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} GB", - "SUBSCRIPTION_NO_SERVERS": "No servers", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", - "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Connected devices:\n", - "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", - "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Connection link:\n{subscription_url}", - "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app", - "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Your import link for the VPN app:\n{subscription_url}", - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device", - "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.", - "BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu", - "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured", - "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.", - "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", - "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", - "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", - "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", - "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", - "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", - "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", - "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", - "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Connect via Happ", - "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Open link in Happ", - "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually: {subscription_link}", - "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.", - "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", - "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", - "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", - "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Step 2 - Add subscription:", - "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Step 3 - Connect:", - "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 How to connect:", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server", - "SUBSCRIPTION_APPS_TITLE": "📱 Apps for {device_name}", - "SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:", - "SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found", - "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", - "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", - "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 How to use:", - "SUBSCRIPTION_LINK_STEP1": "1. Tap the link above to copy it", - "SUBSCRIPTION_LINK_STEP2": "2. Open your VPN app", - "SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option", - "SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link", - "SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.", - "REFERRAL_PROGRAM_TITLE": "👥 Referral program", - "REFERRAL_STATS_HEADER": "📊 Your statistics:", - "REFERRAL_STATS_INVITED": "• Invited users: {count}", - "REFERRAL_STATS_FIRST_TOPUPS": "• Made first top-up: {count}", - "REFERRAL_STATS_ACTIVE": "• Active referrals: {count}", - "REFERRAL_STATS_CONVERSION": "• Conversion: {rate}%", - "REFERRAL_STATS_TOTAL_EARNED": "• Earned in total: {amount}", - "REFERRAL_STATS_MONTH_EARNED": "• Earned last month: {amount}", - "REFERRAL_REWARDS_HEADER": "🎁 How rewards work:", - "REFERRAL_REWARD_NEW_USER": "• New user receives: {bonus} on the first top-up from {minimum}", - "REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: {bonus}", - "REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: {percent}%", - "REFERRAL_LINK_TITLE": "🔗 Your referral link:", - "REFERRAL_CODE_TITLE": "🆔 Your code: {code}", - "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Latest rewards:", - "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up", - "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Top-up commission", - "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Purchase commission", - "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} from {referral_name}", - "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Earnings by type:", - "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Bonuses for first top-ups: {count} ({amount})", - "REFERRAL_EARNINGS_TOPUPS": "• Top-up commissions: {count} ({amount})", - "REFERRAL_EARNINGS_PURCHASES": "• Purchase commissions: {count} ({amount})", - "REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!", - "REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}", - "REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!", - "REFERRAL_LIST_HEADER": "👥 Your referrals (page {current}/{total})", - "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", - "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Top-ups: {count}", - "REFERRAL_LIST_ITEM_EARNED": " 💎 Earned from them: {amount}", - "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago", - "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago", - "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago", - "REFERRAL_LIST_PREV_PAGE": "⬅️ Back", - "REFERRAL_LIST_NEXT_PAGE": "Next ➡️", - "REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics", - "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Earnings by period:", - "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Today: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• Week: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• Month: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• Quarter: {amount}", - "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Top {count} referrals:", - "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} rewards)", - "REFERRAL_ANALYTICS_FOOTER": "📈 Keep growing your referral network!", - "REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!", - "REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!", - "REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection", - "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Servers worldwide", - "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection", - "REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:", - "REFERRAL_SHARE_BUTTON": "📤 Share", - "REFERRAL_INVITE_CREATED_TITLE": "📝 Invitation created!", - "REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:", - "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Balance top-up methods\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:", - "PAYMENT_METHODS_TITLE": "💳 Balance top-up methods", - "PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:", - "PAYMENT_METHODS_FOOTER": "Choose a top-up method:", - "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", - "PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient", - "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Bank card", - "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa", - "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Bank card", - "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", - "PAYMENT_METHOD_MULENPAY_NAME": "💳 Bank card (Mulen Pay)", - "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay", - "PAYMENT_METHOD_PAL24_NAME": "💳 Bank card (PayPalych)", - "PAYMENT_METHOD_PAL24_DESCRIPTION": "via PayPalych", - "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", - "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", - "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", - "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", - "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!", - "TRIAL_INACTIVE_24H": "⏳ A full day passed without activity\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!", - "SUBSCRIPTION_EXPIRED_1D": "⛔ Your subscription expired\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}", - "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.", - "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.", - "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.", - "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.", - "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.", - "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.", - "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", - "NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.", - "NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.", - "NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):", - "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):", - "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):", - "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):", - "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):" + "ACCESS_DENIED": "❌ Access denied", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Insufficient funds\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.", + "ADD_COUNTRIES_BUTTON": "🌐 Add countries", + "ADMIN_CAMPAIGNS": "📣 Promotional campaigns", + "ADMIN_MAIN_MENU": "🏠 Main menu", + "ADMIN_MESSAGES": "📨 Broadcasts", + "ADMIN_MONITORING": "🔍 Monitoring", + "ADMIN_MONITORING_SETTINGS": "⚙️ Monitoring settings", + "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", + "ADMIN_PROMOCODES": "🎫 Promo codes", + "ADMIN_PROMO_GROUPS": "💳 Promo groups", + "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", + "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", + "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", + "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", + "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Add-on purchase discounts: disabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Add-on purchase discounts: enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED_VALUE": "disabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED_VALUE": "enabled", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽", + "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", + "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", + "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT": "Enable discounts for add-on purchases when base discounts are set? (yes/no)", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", + "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:", + "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", + "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", + "ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.", + "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete", + "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.", + "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", + "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", + "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", + "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}", + "ADMIN_PROMO_GROUP_EDIT_ADDON_DISCOUNT_PROMPT": "Enable discounts for add-on purchases? Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100). Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS": "🛒 Add-on purchase discounts", + "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", + "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename", + "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", + "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount", + "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:", + "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”", + "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", + "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100). Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100). Current value: {current}.", + "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Please enter 'yes' or 'no'.", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.", + "ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.", + "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", + "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", + "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", + "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", + "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", + "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", + "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", + "ADMIN_REFERRALS": "🤝 Referral program", + "ADMIN_REMNAWAVE": "🖥️ Remnawave", + "ADMIN_REPORTS": "📊 Reports", + "ADMIN_RULES": "📋 Rules", + "ADMIN_STATISTICS": "📊 Statistics", + "ADMIN_SUBSCRIPTIONS": "📱 Subscriptions", + "ADMIN_TICKETS_TITLE": "🎫 All support tickets:", + "ADMIN_TICKET_REPLY_INPUT": "Enter support reply:", + "ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!", + "ADMIN_USERS": "👥 Users", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_LINE": "Add-on purchase discounts: {status}", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE": "Add-on purchase discounts: —", + "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.", + "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user", + "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group", + "ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}", + "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%, add-ons: {addons}", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.", + "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.", + "ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:", + "ADMIN_USER_PROMO_GROUP_TITLE": "👥 User promo group", + "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”", + "ALREADY_REGISTERED_REFERRAL": "ℹ️ You are already registered. A referral link cannot be applied.", + "ATTACHMENTS_SENT": "✅ Attachments sent.", + "AUTOPAY_BUTTON": "💳 Auto payment", + "AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!", + "AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically", + "AUTOPAY_FAILED": "\n❌ Autopay failed\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n", + "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days", + "AUTOPAY_SUCCESS": "\n✅ Autopay completed\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n", + "BACK": "⬅️ Back", + "BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu", + "BACK_TO_MENU": "🏠 Back to menu", + "BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription", + "BACK_TO_SUPPORT": "⬅️ Back to support", + "BACK_TO_TICKETS": "⬅️ Back to tickets", + "BALANCE_BUTTON": "💰 Balance: {balance}", + "BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}", + "BALANCE_BUTTON_ZERO": "💰 Balance: 0 ₽", + "BALANCE_HISTORY": "📊 Transaction history", + "BALANCE_INFO": "\n💰 Balance: {balance}\n\nChoose an action:\n", + "BALANCE_SUPPORT_REQUEST": "🛠️ Request via support", + "BALANCE_TOPUP": "💳 Top up balance", + "BALANCE_TOP_UP": "💳 Top up", + "BLOCK_BY_TIME": "⏳ Temporary block", + "BLOCK_FOREVER": "🚫 Block permanently", + "BUY_SUBSCRIPTION_START": "\n💎 Subscription setup\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n", + "CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!", + "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!", + "CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.", + "CANCEL": "❌ Cancel", + "CANCEL_REPLY": "❌ Cancel reply", + "CANCEL_TICKET_CREATION": "❌ Cancel ticket creation", + "CHANGE_DEVICES_BUTTON": "📱 Change devices", + "CHANGE_DEVICES_CONFIRM": "\n📱 Confirm change\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", + "CHANGE_DEVICES_INFO": "\n📱 Adjust device limit\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 Important:\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n", + "CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n", + "CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n", + "CHANGE_DEVICES_TITLE": "📱 Change device limit", + "CHANNEL_CHECK_BUTTON": "✅ I have joined", + "CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.", + "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe", + "CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!", + "CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing", + "CHECK_STATUS_BUTTON": "📊 Check status", + "CHOOSE_ANOTHER_DEVICE": "📱 Choose another device", + "CLOSED_TICKETS": "🟢 Closed", + "CLOSED_TICKETS_HEADER": "🟢 Closed tickets", + "CLOSE_NOTIFICATION": "❌ Close notification", + "CLOSE_TICKET": "🔒 Close ticket", + "CONFIRM": "✅ Confirm", + "CONFIRM_CHANGE_BUTTON": "✅ Confirm change", + "CONNECT_BUTTON": "🔗 Connect", + "CONTACT_SUPPORT": "💬 Contact support", + "CONTACT_SUPPORT_BUTTON": "💬 Contact support", + "CONTINUE": "➡️ Continue", + "CONTINUE_BUTTON": "➡️ Continue", + "COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link", + "CREATE_INVITE": "📝 Create invite", + "CREATE_INVITE_BUTTON": "📝 Create invite", + "CREATE_TICKET_BUTTON": "🎫 Create ticket", + "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured", + "DELETE_MESSAGE": "🗑 Delete", + "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", + "DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})", + "DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}", + "DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed", + "DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?", + "DEVICE_GUIDE_ANDROID": "🤖 Android", + "DEVICE_GUIDE_ANDROID_TV": "📺 Android TV", + "DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)", + "DEVICE_GUIDE_MAC": "🎯 macOS", + "DEVICE_GUIDE_WINDOWS": "💻 Windows", + "DISABLE_BUTTON": "❌ Disable", + "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.", + "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.", + "ENABLE_BUTTON": "✅ Enable", + "ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):", + "ERROR": "❌ An error occurred", + "ERROR_RULES_RETRY": "An error occurred. Please try accepting the rules again:", + "ERROR_TRY_AGAIN": "❌ An error occurred. Please try again.", + "GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", + "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_PC": "💻 PC", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.", + "INVALID_AMOUNT": "❌ Invalid amount", + "LANGUAGE_SELECTED": "🌐 Interface language set: English", + "LOADING": "⏳ Loading...", + "MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n", + "MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n", + "MAIN_MENU": "👤 {user_name}\n\n📱 Subscription: {subscription_status}\n\nChoose an option:\n", + "MAIN_MENU_ACTION_PROMPT": "Choose an option:", + "MAIN_MENU_BUTTON": "🏠 Main menu", + "MANAGE_DEVICES_BUTTON": "🔧 Manage devices", + "MARK_AS_ANSWERED": "✅ Mark as answered", + "MENU_ADMIN": "⚙️ Admin panel", + "MENU_BALANCE": "💰 Balance", + "MENU_BUY_SUBSCRIPTION": "💎 Buy subscription", + "MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription", + "MENU_LANGUAGE": "🌐 Language", + "MENU_PROMOCODE": "🎫 Promo code", + "MENU_REFERRALS": "🤝 Referral program", + "MENU_RULES": "📋 Service rules", + "MENU_SUBSCRIPTION": "📱 Subscription", + "MENU_SUPPORT": "🛠️ Support", + "MENU_TRIAL": "🎁 Trial subscription", + "MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.", + "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Mulen Pay payment\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 How to pay:\n1. Press ‘Pay with Mulen Pay’\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", + "MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay", + "MULENPAY_TOPUP_PROMPT": "💳 Mulen Pay payment\n\nEnter an amount between 100 and 100,000 ₽.\nThe payment is processed by the secure Mulen Pay platform.", + "MY_BALANCE_BUTTON": "💰 My balance", + "MY_SUBSCRIPTION_BUTTON": "📱 My subscription", + "MY_TICKETS_BUTTON": "📋 My tickets", + "MY_TICKETS_TITLE": "📋 Your tickets:", + "NO": "❌ No", + "NOTIFICATION_CLOSED": "Notification closed.", + "NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.", + "NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.", + "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):", + "NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):", + "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):", + "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):", + "NO_ATTACHMENTS": "No attachments.", + "NO_SERVERS_AVAILABLE": "❌ No servers available", + "NO_TICKETS": "You don't have any tickets yet.", + "NO_TICKETS_ADMIN": "No tickets to display.", + "NO_TRAFFIC_PACKAGES": "❌ No packages available", + "OPEN_TICKETS": "🔴 Open", + "OPEN_TICKETS_HEADER": "🔴 Open tickets", + "OPERATION_CANCELLED": "❌ Operation cancelled", + "OTHER_APPS_BUTTON": "📋 Other apps", + "PAGINATION_NEXT": "➡️", + "PAGINATION_PREV": "⬅️", + "PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.", + "PAL24_PAYMENT_INSTRUCTIONS": "💳 PayPalych payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\n1. Press ‘Pay with PayPalych’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", + "PAL24_PAY_BUTTON": "💳 Pay with PayPalych", + "PAL24_TOPUP_PROMPT": "💳 PayPalych payment\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed by the secure PayPalych platform.", + "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable", + "PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)", + "PAYMENT_CARD_PAL24": "💳 Bank card (PayPalych)", + "PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)", + "PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)", + "PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)", + "PAYMENT_METHODS_FOOTER": "Choose a top-up method:", + "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Balance top-up methods\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:", + "PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:", + "PAYMENT_METHODS_TITLE": "💳 Balance top-up methods", + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", + "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", + "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", + "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay", + "PAYMENT_METHOD_MULENPAY_NAME": "💳 Bank card (Mulen Pay)", + "PAYMENT_METHOD_PAL24_DESCRIPTION": "via PayPalych", + "PAYMENT_METHOD_PAL24_NAME": "💳 Bank card (PayPalych)", + "PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient", + "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", + "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", + "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", + "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", + "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Bank card", + "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa", + "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Bank card", + "PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)", + "PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars", + "PAYMENT_VIA_SUPPORT": "🛠️ Via support", + "PAY_NOW_BUTTON": "💳 Pay", + "PAY_WITH_COINS_BUTTON": "🪙 Pay", + "PENDING_CANCEL_BUTTON": "⌛ Cancel", + "PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}", + "PERIOD_180_DAYS": "📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}", + "PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}", + "PERIOD_360_DAYS": "📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}", + "PERIOD_60_DAYS": "📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}", + "PERIOD_90_DAYS": "📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}", + "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀", + "PROMOCODE_EMPTY_INPUT": "❌ Please enter a valid promo code", + "PROMOCODE_ENTER": "🎫 Enter promo code", + "PROMOCODE_EXPIRED": "❌ Promo code has expired", + "PROMOCODE_INVALID": "❌ Invalid promo code", + "PROMOCODE_SUCCESS": "🎉 Promo code applied!", + "PROMOCODE_USED": "ℹ️ Promo code has already been used", + "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Your promo group discounts", + "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%", + "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%", + "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%", + "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:", + "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", + "REFERRAL_ANALYTICS_BUTTON": "📊 Analytics", + "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Earnings by period:", + "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• Month: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• Quarter: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Today: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• Week: {amount}", + "REFERRAL_ANALYTICS_FOOTER": "📈 Keep growing your referral network!", + "REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics", + "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} rewards)", + "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Top {count} referrals:", + "REFERRAL_CODE_ACCEPTED": "✅ Referral code accepted!", + "REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.", + "REFERRAL_CODE_INVALID": "❌ Invalid referral code", + "REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.", + "REFERRAL_CODE_QUESTION": "\n🤝 Do you have a friend's referral code?\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n", + "REFERRAL_CODE_SKIP": "⏭️ Skip", + "REFERRAL_CODE_TITLE": "🆔 Your code: {code}", + "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Earnings by type:", + "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Bonuses for first top-ups: {count} ({amount})", + "REFERRAL_EARNINGS_PURCHASES": "• Purchase commissions: {count} ({amount})", + "REFERRAL_EARNINGS_TOPUPS": "• Top-up commissions: {count} ({amount})", + "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Purchase commission", + "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Top-up commission", + "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up", + "REFERRAL_INFO": "\n🤝 Referral program\n\n👥 Invited: {referrals_count} friends\n💰 Earned: {earned_amount}\n\n🔗 Your referral link:\n{referral_link}\n\n🎫 Your promo code:\n{referral_code}\n\n💰 Terms:\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n", + "REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!", + "REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:", + "REFERRAL_INVITE_CREATED_TITLE": "📝 Invitation created!", + "REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection", + "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection", + "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Servers worldwide", + "REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!", + "REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:", + "REFERRAL_INVITE_MESSAGE": "\n🎯 Invitation to the VPN service\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n", + "REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!", + "REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}", + "REFERRAL_LINK_TITLE": "🔗 Your referral link:", + "REFERRAL_LIST_BUTTON": "👥 Referral list", + "REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!", + "REFERRAL_LIST_HEADER": "👥 Your referrals (page {current}/{total})", + "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago", + "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago", + "REFERRAL_LIST_ITEM_EARNED": " 💎 Earned from them: {amount}", + "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", + "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago", + "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Top-ups: {count}", + "REFERRAL_LIST_NEXT_PAGE": "Next ➡️", + "REFERRAL_LIST_PREV_PAGE": "⬅️ Back", + "REFERRAL_PROGRAM_TITLE": "👥 Referral program", + "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Latest rewards:", + "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} from {referral_name}", + "REFERRAL_REWARDS_HEADER": "🎁 How rewards work:", + "REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: {percent}%", + "REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: {bonus}", + "REFERRAL_REWARD_NEW_USER": "• New user receives: {bonus} on the first top-up from {minimum}", + "REFERRAL_SHARE_BUTTON": "📤 Share", + "REFERRAL_STATS_ACTIVE": "• Active referrals: {count}", + "REFERRAL_STATS_CONVERSION": "• Conversion: {rate}%", + "REFERRAL_STATS_FIRST_TOPUPS": "• Made first top-up: {count}", + "REFERRAL_STATS_HEADER": "📊 Your statistics:", + "REFERRAL_STATS_INVITED": "• Invited users: {count}", + "REFERRAL_STATS_MONTH_EARNED": "• Earned last month: {amount}", + "REFERRAL_STATS_TOTAL_EARNED": "• Earned in total: {amount}", + "REGISTRATION_COMPLETING": "✅ Completing registration...", + "REPLY_TO_TICKET": "💬 Reply", + "REPORT_CLOSE": "❌ Close", + "REPORT_CLOSED": "✅ Report closed.", + "REPORT_CLOSE_ERROR": "❌ Failed to close the report.", + "RESET_ALL_DEVICES_BUTTON": "🔄 Reset all devices", + "RESET_DEVICE_CONFIRM_BUTTON": "✅ Reset this device", + "RESET_TRAFFIC_BUTTON": "🔄 Reset traffic", + "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout", + "RULES_ACCEPT": "✅ I accept the rules", + "RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...", + "RULES_DECLINE": "❌ I do not accept", + "RULES_HEADER": "📋 Service Rules", + "RULES_REQUIRED": "❗️ You must accept the rules to use the service!", + "RULES_TEXT_DEFAULT": "📋 Service Usage Rules\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\nBy using the service you agree to follow these rules.", + "SELECT_COUNTRIES": "Select countries:", + "SELECT_DEVICES": "Number of devices:", + "SELECT_PERIOD": "Choose period:", + "SELECT_TRAFFIC": "Choose traffic package:", + "SENDING_ATTACHMENTS": "📎 Sending attachments...", + "SEND_CONTACT_BUTTON": "📱 Share contact", + "SEND_LOCATION_BUTTON": "📍 Share location", + "SHOW_QR_BUTTON": "📱 Show QR code", + "SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link", + "SKIP_BUTTON": "Skip ➡️", + "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.", + "STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.", + "STARS_PAYMENT_SUCCESS": "🎉 Payment processed successfully!\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀", + "STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.", + "STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.", + "STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.", + "STARS_PRECHECK_USER_NOT_FOUND": "User not found. Please contact support.", + "SUBSCRIPTION_ACTIVE": "✅ Active", + "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", + "SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:", + "SUBSCRIPTION_APPS_TITLE": "📱 Apps for {device_name}", + "SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Connected devices:\n", + "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", + "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", + "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", + "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app", + "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Connection link:\n{subscription_url}", + "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", + "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", + "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", + "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server", + "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 How to connect:", + "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", + "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Step 2 - Add subscription:", + "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Step 3 - Connect:", + "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", + "SUBSCRIPTION_EXPIRED": "\n❌ Subscription expired\n\nYour subscription has ended. Renew it to restore access.\n", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Your subscription expired\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.", + "SUBSCRIPTION_EXPIRING": "\n⚠️ Subscription expiring!\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n", + "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Subscription expires in {days_text}!\n\nYour paid subscription ends on {end_date}.\n\n💳 Autopay: {autopay_status}\n\n{action_text}\n", + "SUBSCRIPTION_EXTEND": "💎 Extend subscription", + "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.", + "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.", + "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually: {subscription_link}", + "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Open link in Happ", + "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Connect via Happ", + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device", + "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Your import link for the VPN app:\n{subscription_url}", + "SUBSCRIPTION_INFO": "\n📱 Subscription details\n\n📊 Status: {status}\n🎭 Type: {type}\n📅 Valid until: {end_date}\n⏰ Days left: {days_left}\n\n📈 Traffic: {traffic_used} / {traffic_limit}\n🌍 Servers: {countries_count} countries\n📱 Devices: {devices_used} / {devices_limit}\n\n💳 Autopay: {autopay_status}\n", + "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.", + "SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.", + "SUBSCRIPTION_LINK_STEP1": "1. Tap the link above to copy it", + "SUBSCRIPTION_LINK_STEP2": "2. Open your VPN app", + "SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option", + "SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link", + "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", + "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 How to use:", + "SUBSCRIPTION_NONE": "❌ No active subscription", + "SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found", + "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", + "SUBSCRIPTION_NO_SERVERS": "No servers", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", + "SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!", + "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings", + "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", + "SUBSCRIPTION_STATUS_ACTIVE": "Active", + "SUBSCRIPTION_STATUS_EXPIRED": "Expired", + "SUBSCRIPTION_STATUS_TRIAL": "Trial", + "SUBSCRIPTION_STATUS_UNKNOWN": "Unknown", + "SUBSCRIPTION_SUMMARY": "\n📋 Final configuration\n\n📅 Period: {period} days\n📈 Traffic: {traffic}\n🌍 Countries: {countries}\n📱 Devices: {devices}\n\n💰 Total: {total_price}\n\nConfirm the purchase?\n", + "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} days", + "SUBSCRIPTION_TIME_LEFT_EXPIRED": "expired", + "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} hr", + "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} min", + "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} GB", + "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (unlimited) | Used: {used} GB", + "SUBSCRIPTION_TRIAL": "🧪 Trial subscription", + "SUBSCRIPTION_TYPE_PAID": "Paid", + "SUBSCRIPTION_TYPE_TRIAL": "Trial", + "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 expires in a few minutes!", + "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ expires today!", + "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ expires tomorrow!", + "SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days", + "SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)", + "SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!", + "SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!", + "SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}", + "SUB_STATUS_NONE": "❌ Not available", + "SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)", + "SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!", + "SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!", + "SUCCESS": "✅ Success", + "SUPPORT_BUTTON": "🆘 Support", + "SUPPORT_INFO": "\n🛠️ Technical support\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n", + "SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic", + "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Confirm traffic change\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", + "SWITCH_TRAFFIC_INFO": "\n🔄 Switch traffic limit\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 Important:\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n", + "SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Traffic limit decreased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\nℹ️ Payments are not refunded\n", + "SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Traffic limit increased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\n💰 Charged: {amount}\n", + "SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit", + "TICKET_ATTACHMENTS": "📎 Attachments", + "TICKET_CLOSED": "✅ Ticket closed.", + "TICKET_CLOSE_ERROR": "❌ Error closing ticket.", + "TICKET_CREATED_SUCCESS": "✅ Ticket #{ticket_id} created successfully!\n\nTitle: {title}\n\nWe will respond to you soon.", + "TICKET_CREATION_CANCELLED": "Ticket creation cancelled.", + "TICKET_CREATION_ERROR": "❌ An error occurred while creating the ticket. Please try again later.", + "TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.", + "TICKET_MESSAGE_INPUT": "Now describe your problem or question:", + "TICKET_MESSAGE_TOO_SHORT": "Message must contain at least 10 characters. Try again:", + "TICKET_NOT_FOUND": "Ticket not found.", + "TICKET_PRIORITY_HIGH": "🟠 High", + "TICKET_PRIORITY_LOW": "🟢 Low", + "TICKET_PRIORITY_NORMAL": "🟡 Normal", + "TICKET_PRIORITY_SELECT": "Select ticket priority:", + "TICKET_PRIORITY_URGENT": "🔴 Urgent", + "TICKET_REPLY_CANCELLED": "Reply cancelled.", + "TICKET_REPLY_ERROR": "❌ An error occurred while sending the reply. Please try again later.", + "TICKET_REPLY_INPUT": "Enter your reply:", + "TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:", + "TICKET_REPLY_SENT": "✅ Your reply has been sent!", + "TICKET_REPLY_TOO_SHORT": "Reply must contain at least 5 characters. Try again:", + "TICKET_STATUS_ANSWERED": "Answered", + "TICKET_STATUS_CLOSED": "Closed", + "TICKET_STATUS_OPEN": "Open", + "TICKET_STATUS_PENDING": "Pending", + "TICKET_TITLE_INPUT": "Enter ticket title:", + "TICKET_TITLE_TOO_LONG": "Title is too long. Maximum 255 characters. Try again:", + "TICKET_TITLE_TOO_SHORT": "Title must contain at least 5 characters. Try again:", + "TICKET_UPDATE_ERROR": "❌ Error updating ticket.", + "TOPUP_BALANCE_BUTTON": "💳 Top up balance", + "TOP_UP_AMOUNT": "💳 Enter top-up amount (in rubles):", + "TOP_UP_METHODS": "\n💳 Select a payment method\n\nAmount: {amount}\n", + "TOP_UP_STARS": "⭐ Telegram Stars", + "TOP_UP_TRIBUTE": "💎 Bank card", + "TRAFFIC_100GB": "📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", + "TRAFFIC_10GB": "📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", + "TRAFFIC_250GB": "📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", + "TRAFFIC_25GB": "📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}", + "TRAFFIC_50GB": "📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}", + "TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", + "TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", + "TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed", + "TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured", + "TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", + "TRIAL_ACTIVATED": "🎉 Trial subscription activated!", + "TRIAL_ACTIVATE_BUTTON": "🎁 Activate", + "TRIAL_ALREADY_USED": "❌ The trial subscription has already been used", + "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic} GB\n📱 Devices: {devices} pcs\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", + "TRIAL_ENDING_SOON": "\n🎁 The trial subscription is ending soon!\n\nYour trial expires in a few hours.\n\n💎 Don't want to lose VPN access?\nSwitch to the full subscription!\n\n🔥 Special offer:\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n", + "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!", + "TRIAL_INACTIVE_24H": "⏳ A full day passed without activity\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!", + "UNBLOCK": "✅ Unblock", + "UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.", + "UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.", + "USER_NOT_FOUND": "❌ User not found", + "VIEW_TICKET": "👁️ View ticket", + "WELCOME": "\n🎉 Welcome to VPN Service!\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 Advantages:\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n", + "WELCOME_FALLBACK": "Welcome, {user_name}!", + "YES": "✅ Yes" } diff --git a/locales/ru.json b/locales/ru.json index 2524c1d4..8532957c 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1,525 +1,534 @@ { - "ACCESS_DENIED": "❌ Доступ запрещен", - "ADD_COUNTRIES_BUTTON": "🌐 Добавить страны", - "ADMIN_MAIN_MENU": "🏠 Главное меню", - "ADMIN_CAMPAIGNS": "📣 Рекламные кампании", - "ADMIN_MESSAGES": "📨 Рассылки", - "ADMIN_MONITORING": "🔍 Мониторинг", - "ADMIN_MONITORING_SETTINGS": "⚙️ Настройки мониторинга", - "ADMIN_REPORTS": "📊 Отчеты", - "ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n", - "ADMIN_PROMOCODES": "🎫 Промокоды", - "ADMIN_REFERRALS": "🤝 Партнерка", - "ADMIN_REMNAWAVE": "🖥️ Remnawave", - "ADMIN_RULES": "📋 Правила", - "ADMIN_STATISTICS": "📊 Статистика", - "ADMIN_PROMO_GROUPS": "💳 Промогруппы", - "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", - "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", - "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", - "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", - "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", - "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", - "CREATE_TICKET_BUTTON": "🎫 Создать тикет", - "MY_TICKETS_BUTTON": "📋 Мои тикеты", - "CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой", - "SUPPORT_BUTTON": "🆘 Поддержка", - "TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:", - "TICKET_PRIORITY_LOW": "🟢 Низкий", - "TICKET_PRIORITY_NORMAL": "🟡 Обычный", - "TICKET_PRIORITY_HIGH": "🟠 Высокий", - "TICKET_PRIORITY_URGENT": "🔴 Срочный", - "CANCEL_TICKET_CREATION": "❌ Отменить создание тикета", - "TICKET_TITLE_INPUT": "Введите заголовок тикета:", - "TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:", - "TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:", - "TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото c подписью:", - "TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:", - "TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.", - "VIEW_TICKET": "👁️ Посмотреть тикет", - "BACK_TO_MENU": "🏠 В главное меню", - "TICKET_CREATION_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.", - "NO_TICKETS": "У вас пока нет тикетов.", - "MY_TICKETS_TITLE": "📋 Ваши тикеты:", - "TICKET_STATUS_OPEN": "Открыт", - "TICKET_STATUS_ANSWERED": "Отвечен", - "TICKET_STATUS_CLOSED": "Закрыт", - "TICKET_STATUS_PENDING": "В ожидании", - "REPLY_TO_TICKET": "💬 Ответить", - "CLOSE_TICKET": "🔒 Закрыть тикет", - "CANCEL_REPLY": "❌ Отменить ответ", - "TICKET_REPLY_INPUT": "Введите ваш ответ:", - "TICKET_REPLY_TOO_SHORT": "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:", - "TICKET_REPLY_SENT": "✅ Ваш ответ отправлен!", - "TICKET_REPLY_ERROR": "❌ Произошла ошибка при отправке ответа. Попробуйте позже.", - "TICKET_CLOSED": "✅ Тикет закрыт.", - "TICKET_CLOSE_ERROR": "❌ Ошибка при закрытии тикета.", - "TICKET_NOT_FOUND": "Тикет не найден.", - "TICKET_CREATION_CANCELLED": "Создание тикета отменено.", - "BACK_TO_SUPPORT": "⬅️ К поддержке", - "TICKET_REPLY_CANCELLED": "Ответ отменен.", - "BACK_TO_TICKETS": "⬅️ К тикетам", - "NO_TICKETS_ADMIN": "Нет тикетов для отображения.", - "ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:", - "ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:", - - "ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!", - "TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.", - "TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.", - "MARK_AS_ANSWERED": "✅ Отметить как отвеченный", - "TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:", - "CLOSE_NOTIFICATION": "❌ Закрыть уведомление", - "REPORT_CLOSE": "❌ Закрыть", - "REPORT_CLOSED": "✅ Отчет закрыт.", - "REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.", - "NOTIFICATION_CLOSED": "Уведомление закрыто.", - "UNBLOCK": "✅ Разблокировать", - "BLOCK_FOREVER": "🚫 Заблокировать", - "BLOCK_BY_TIME": "⏳ Блокировка по времени", - "ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):", - "TICKET_ATTACHMENTS": "📎 Вложения", - "OPEN_TICKETS": "🔴 Открытые", - "CLOSED_TICKETS": "🟢 Закрытые", - "CLOSED_TICKETS_HEADER": "🟢 Закрытые тикеты", - "OPEN_TICKETS_HEADER": "🔴 Открытые тикеты", - "SENDING_ATTACHMENTS": "📎 Отправляю вложения...", - "NO_ATTACHMENTS": "Вложений нет.", - "ATTACHMENTS_SENT": "✅ Вложения отправлены.", - "DELETE_MESSAGE": "🗑 Удалить", - "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа", - "ADMIN_USER_PROMO_GROUP_TITLE": "👥 Промогруппа пользователя", - "ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}", - "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена", - "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", - "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.", - "ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:", - "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»", - "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.", - "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.", - "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", - "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}", - "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", - "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", - "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", - "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", - "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить", - "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:", - "ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.", - "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", - "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", - "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", - "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", - "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", - "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", - "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", - "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100). Текущее значение: {current}.", - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100). Текущее значение: {current}.", - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100). Текущее значение: {current}.", - "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", - "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽", - "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»", - "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:", - "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название", - "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик", - "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", - "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", - "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", - "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", - "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", - "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", - "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", - "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", - "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.", - "ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.", - "ADMIN_SUBSCRIPTIONS": "📱 Подписки", - "ADMIN_USERS": "👥 Пользователи", - "AUTOPAY_BUTTON": "💳 Автоплатёж", - "AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!", - "AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически", - "AUTOPAY_FAILED": "\n❌ Ошибка автоплатежа\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n", - "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни", - "AUTOPAY_SUCCESS": "\n✅ Автоплатеж выполнен\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n", - "BACK": "⬅️ Назад", - "BACK_TO_SUBSCRIPTION": "⬅️ К подписке", - "BALANCE_BUTTON": "💰 Баланс: {balance}", - "BALANCE_BUTTON_DEFAULT": "💰 Баланс: {balance}", - "BALANCE_BUTTON_ZERO": "💰 Баланс: 0 ₽", - "BALANCE_HISTORY": "📊 История операций", - "BALANCE_INFO": "\n💰 Баланс: {balance}\n\nВыберите действие:\n", - "BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку", - "BALANCE_TOP_UP": "💳 Пополнить", - "BALANCE_TOPUP": "💳 Пополнить баланс", - "CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.", - "CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!", - "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!", - "BUY_SUBSCRIPTION_START": "\n💎 Настройка подписки\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n", - "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Скидки вашей промогруппы", - "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%", - "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%", - "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%", - "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:", - "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", - "CANCEL": "❌ Отмена", - "CHANGE_DEVICES_BUTTON": "📱 Изменить устройства", - "CHANGE_DEVICES_CONFIRM": "\n 📱 Подтверждение изменения\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ", - "CHANGE_DEVICES_INFO": "\n 📱 Изменение количества устройств\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 Важно:\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ", - "CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ", - "CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ", - "CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств", - "CHANNEL_CHECK_BUTTON": "✅ Я подписался", - "CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.", - "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться", - "CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!", - "CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку", - "CHECK_STATUS_BUTTON": "📊 Проверить статус", - "CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство", - "CONFIRM": "✅ Подтвердить", - "CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение", - "CONNECT_BUTTON": "🔗 Подключиться", - "HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ", - "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", - "HAPP_PLATFORM_IOS": "🍎 iOS", - "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", - "HAPP_PLATFORM_WINDOWS": "💻 Windows", - "HAPP_PLATFORM_PC": "💻 ПК", - "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", - "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", - "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", - "CONTACT_SUPPORT": "💬 Написать в поддержку", - "CONTINUE": "➡️ Продолжить", - "CONTINUE_BUTTON": "✅ Продолжить", - "COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки", - "CREATE_INVITE": "📝 Создать приглашение", - "CREATE_INVITE_BUTTON": "📝 Создать приглашение", - "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}", - "DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})", - "DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}", - "DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось", - "DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?", - "DEVICE_GUIDE_ANDROID": "🤖 Android", - "DEVICE_GUIDE_ANDROID_TV": "📺 Android TV", - "DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)", - "DEVICE_GUIDE_MAC": "🎯 macOS", - "DEVICE_GUIDE_WINDOWS": "💻 Windows", - "DISABLE_BUTTON": "❌ Выключить", - "ENABLE_BUTTON": "✅ Включить", - "ERROR": "❌ Произошла ошибка", - "ERROR_TRY_AGAIN": "❌ Произошла ошибка. Попробуйте еще раз.", - "ERROR_RULES_RETRY": "Произошла ошибка. Попробуйте принять правила еще раз:", - "GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса", - "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки", - "INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n Пополните баланс на {amount} и попробуйте снова.\n ", - "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Недостаточно средств\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.", - "INVALID_AMOUNT": "❌ Неверная сумма", - "LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: Русский", - "LOADING": "⏳ Загрузка...", - "MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n", - "MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n", - "MAIN_MENU": "👤 {user_name}\n \n📱 Подписка: {subscription_status}\n\nВыберите действие:\n", - "MAIN_MENU_ACTION_PROMPT": "Выберите действие:", - "MAIN_MENU_BUTTON": "🏠 Главное меню", - "MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами", - "MENU_ADMIN": "⚙️ Админ-панель", - "MENU_BALANCE": "💰 Баланс", - "MENU_BUY_SUBSCRIPTION": "💎 Купить подписку", - "MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку", - "MENU_LANGUAGE": "🌐 Язык", - "MENU_PROMOCODE": "🎫 Промокод", - "MENU_REFERRALS": "🤝 Партнерка", - "MENU_RULES": "📋 Правила сервиса", - "MENU_SUBSCRIPTION": "📱 Подписка", - "MENU_SUPPORT": "🛠️ Техподдержка", - "MENU_TRIAL": "🧪 Тестовая подписка", - "MY_BALANCE_BUTTON": "💰 Мой баланс", - "MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка", - "NO": "❌ Нет", - "NO_SERVERS_AVAILABLE": "❌ Нет доступных серверов", - "NO_TRAFFIC_PACKAGES": "❌ Нет доступных пакетов", - "OPERATION_CANCELLED": "❌ Операция отменена", - "OTHER_APPS_BUTTON": "📋 Другие приложения", - "PAGINATION_NEXT": "➡️", - "PAGINATION_PREV": "⬅️", - "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны", - "PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)", - "PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)", - "PAYMENT_CARD_PAL24": "💳 Банковская карта (PayPalych)", - "PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)", - "PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)", - "PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)", - "PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars", - "PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку", - "PAY_NOW_BUTTON": "💳 Оплатить", - "PAY_WITH_COINS_BUTTON": "🪙 Оплатить", - "MULENPAY_TOPUP_PROMPT": "💳 Оплата через Mulen Pay\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу Mulen Pay.", - "MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.", - "MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay", - "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Оплата через Mulen Pay\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", - "PAL24_TOPUP_PROMPT": "💳 Оплата через PayPalych\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через защищенную платформу PayPalych.", - "PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", - "PAL24_PAY_BUTTON": "💳 Оплатить через PayPalych", - "PAL24_PAYMENT_INSTRUCTIONS": "💳 Оплата через PayPalych\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через PayPalych’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", - "PENDING_CANCEL_BUTTON": "⌛ Отмена", - "PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}", - "PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}", - "PERIOD_30_DAYS": "📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}", - "PERIOD_360_DAYS": "📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}", - "PERIOD_60_DAYS": "📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}", - "PERIOD_90_DAYS": "📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}", - "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Подключиться бесплатно 🚀", - "PROMOCODE_ENTER": "🎫 Введите промокод:", - "PROMOCODE_EMPTY_INPUT": "❌ Введите корректный промокод", - "PROMOCODE_EXPIRED": "❌ Промокод истек", - "PROMOCODE_INVALID": "❌ Неверный промокод", - "PROMOCODE_SUCCESS": "🎉 Промокод активирован! {description}", - "PROMOCODE_USED": "❌ Промокод уже использован", - "REFERRAL_ANALYTICS_BUTTON": "📊 Аналитика", - "REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.", - "REFERRAL_CODE_ACCEPTED": "✅ Реферальный код принят!", - "REFERRAL_CODE_INVALID": "❌ Неверный реферальный код", - "REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start", - "REFERRAL_CODE_QUESTION": "\n🤝 У вас есть реферальный код от друга?\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n", - "REFERRAL_CODE_SKIP": "⏭️ Пропустить", - "ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", - "REFERRAL_INFO": "\n🤝 Реферальная программа\n\n👥 Приглашено: {referrals_count} друзей\n💰 Заработано: {earned_amount}\n\n🔗 Ваша реферальная ссылка:\n{referral_link}\n\n🎫 Ваш промокод:\n{referral_code}\n\n💰 Условия:\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n", - "REFERRAL_INVITE_MESSAGE": "\n🎯 Приглашение в VPN сервис\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n", - "REFERRAL_LIST_BUTTON": "👥 Список рефералов", - "RESET_ALL_DEVICES_BUTTON": "🔄 Сбросить все устройства", - "RESET_DEVICE_CONFIRM_BUTTON": "✅ Да, сбросить это устройство", - "RESET_TRAFFIC_BUTTON": "🔄 Сбросить трафик", - "RULES_ACCEPT": "✅ Принимаю правила", - "RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...", - "RULES_DECLINE": "❌ Не принимаю", - "RULES_HEADER": "📋 Правила сервиса", - "RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!", - "RULES_TEXT_DEFAULT": "📋 Правила использования сервиса\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\nИспользуя сервис, вы подтверждаете согласие с этими правилами.", - "SELECT_COUNTRIES": "Выберите страны:", - "SELECT_DEVICES": "Количество устройств:", - "SELECT_PERIOD": "Выберите период:", - "SELECT_TRAFFIC": "Выберите пакет трафика:", - "SEND_CONTACT_BUTTON": "📱 Отправить контакт", - "SEND_LOCATION_BUTTON": "📍 Отправить геолокацию", - "SHOW_QR_BUTTON": "📱 Показать QR код", - "SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки", - "SKIP_BUTTON": "⏭️ Пропустить", - "SUBSCRIPTION_ACTIVE": "✅ Активна", - "SUBSCRIPTION_EXTEND": "💎 Продлить подписку", - "SUBSCRIPTION_EXPIRED": "\n❌ Подписка истекла\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n", - "SUBSCRIPTION_EXPIRING": "\n⚠️ Подписка истекает!\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n", - "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Подписка истекает через {days_text}!\n\nВаша платная подписка истекает {end_date}.\n\n💳 Автоплатеж: {autopay_status}\n\n{action_text}\n", - "SUBSCRIPTION_INFO": "\n📱 Информация о подписке\n\n📊 Статус: {status}\n🎭 Тип: {type}\n📅 Действует до: {end_date}\n⏰ Осталось дней: {days_left}\n\n📈 Трафик: {traffic_used} / {traffic_limit}\n🌍 Серверы: {countries_count} стран\n📱 Устройства: {devices_used} / {devices_limit}\n\n💳 Автоплатеж: {autopay_status}\n", - "SUBSCRIPTION_NONE": "❌ Нет активной подписки", - "SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена", - "SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!", - "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки", - "SUBSCRIPTION_SUMMARY": "\n📋 Итоговая конфигурация\n\n📅 Период: {period} дней\n📈 Трафик: {traffic}\n🌍 Страны: {countries}\n📱 Устройства: {devices}\n\n💰 Итого к оплате: {total_price}\n\nПодтвердить покупку?\n", - "SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка", - "SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Активна\n⚠️ истекает через {days} дн.", - "SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)", - "SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!", - "SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!", - "SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}", - "SUB_STATUS_NONE": "❌ Отсутствует", - "SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)", - "SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!", - "SUB_STATUS_TRIAL_TOMORROW": "🎁 Тестовая подписка\n⚠️ истекает завтра!", - "SUCCESS": "✅ Успешно", - "REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...", - "SUPPORT_INFO": "\n🛠️ Техническая поддержка\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n", - "SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик", - "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Подтверждение переключения трафика\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n", - "SWITCH_TRAFFIC_INFO": "\n🔄 Переключение лимита трафика\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 Важно:\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n", - "SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Лимит трафика уменьшен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\nℹ️ Возврат средств не производится\n", - "SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Лимит трафика увеличен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\n💰 Списано: {amount}\n", - "SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика", - "TOPUP_BALANCE_BUTTON": "💳 Попол\\у043Dить баланс", - "TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):", - "TOP_UP_METHODS": "\n💳 Выберите способ оплаты\n\nСумма: {amount}\n", - "TOP_UP_STARS": "⭐ Telegram Stars", - "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.", - "STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.", - "STARS_PAYMENT_SUCCESS": "🎉 Платеж успешно обработан!\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀", - "STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.", - "STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.", - "STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.", - "STARS_PRECHECK_USER_NOT_FOUND": "Пользователь не найден. Обратитесь в поддержку.", - "TOP_UP_TRIBUTE": "💎 Банковская карта", - "TRAFFIC_100GB": "📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", - "TRAFFIC_10GB": "📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", - "TRAFFIC_250GB": "📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", - "TRAFFIC_25GB": "📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}", - "TRAFFIC_50GB": "📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}", - "TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", - "TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}", - "TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился", - "TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены", - "TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", - "TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!", - "TRIAL_ACTIVATE_BUTTON": "🎁 Активировать", - "TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована", - "TRIAL_AVAILABLE": "\n🎁 Тестовая подписка\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ Период: {days} дней\n📈 Трафик: {traffic} ГБ\n📱 Устройства: {devices} шт.\n🌍 Сервер: {server_name}\n\nАктивировать тестовую подписку?\n", - "TRIAL_ENDING_SOON": "\n🎁 Тестовая подписка скоро закончится!\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 Не хотите остаться без VPN?\nПереходите на полную подписку!\n\n🔥 Специальное предложение:\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n", - "UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.", - "UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.", - "USER_NOT_FOUND": "❌ Пользователь не найден", - "WELCOME": "\n🎉 Добро пожаловать в VPN сервис!\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 Преимущества:\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n", - "WELCOME_FALLBACK": "Добро пожаловать, {user_name}!", - "YES": "✅ Да", - "SUBSCRIPTION_STATUS_EXPIRED": "Истекла", - "SUBSCRIPTION_STATUS_TRIAL": "Тестовая", - "SUBSCRIPTION_STATUS_ACTIVE": "Активна", - "SUBSCRIPTION_STATUS_UNKNOWN": "Неизвестно", - "SUBSCRIPTION_TIME_LEFT_EXPIRED": "истёк", - "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} дн.", - "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} ч.", - "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} мин.", - "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ истекает завтра!", - "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ истекает сегодня!", - "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 истекает через несколько минут!", - "SUBSCRIPTION_TYPE_TRIAL": "Триал", - "SUBSCRIPTION_TYPE_PAID": "Платная", - "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (безлимит) | Использовано: {used} ГБ", - "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} ГБ", - "SUBSCRIPTION_NO_SERVERS": "Нет серверов", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", - "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Подключенные устройства:\n", - "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", - "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Ссылка для подключения:\n{subscription_url}", - "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", - "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", - "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню", - "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена", - "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", - "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", - "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", - "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", - "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", - "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", - "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", - "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", - "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", - "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Подключение через Happ", - "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Открыть ссылку в Happ", - "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", - "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.", - "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", - "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", - "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", - "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Шаг 2 - Добавление подписки:", - "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Шаг 3 - Подключение:", - "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 Как подключить:", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу", - "SUBSCRIPTION_APPS_TITLE": "📱 Приложения для {device_name}", - "SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:", - "SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено", - "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", - "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", - "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 Как использовать:", - "SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать", - "SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение", - "SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"", - "SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку", - "SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", - "REFERRAL_PROGRAM_TITLE": "👥 Реферальная программа", - "REFERRAL_STATS_HEADER": "📊 Ваша статистика:", - "REFERRAL_STATS_INVITED": "• Приглашено пользователей: {count}", - "REFERRAL_STATS_FIRST_TOPUPS": "• Сделали первое пополнение: {count}", - "REFERRAL_STATS_ACTIVE": "• Активных рефералов: {count}", - "REFERRAL_STATS_CONVERSION": "• Конверсия: {rate}%", - "REFERRAL_STATS_TOTAL_EARNED": "• Заработано всего: {amount}", - "REFERRAL_STATS_MONTH_EARNED": "• За последний месяц: {amount}", - "REFERRAL_REWARDS_HEADER": "🎁 Как работают награды:", - "REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: {bonus} при первом пополнении от {minimum}", - "REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: {bonus}", - "REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: {percent}%", - "REFERRAL_LINK_TITLE": "🔗 Ваша реферальная ссылка:", - "REFERRAL_CODE_TITLE": "🆔 Ваш код: {code}", - "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Последние начисления:", - "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение", - "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Комиссия с пополнения", - "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Комиссия с покупки", - "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} от {referral_name}", - "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Доходы по типам:", - "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Бонусы за первые пополнения: {count} ({amount})", - "REFERRAL_EARNINGS_TOPUPS": "• Комиссии с пополнений: {count} ({amount})", - "REFERRAL_EARNINGS_PURCHASES": "• Комиссии с покупок: {count} ({amount})", - "REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!", - "REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}", - "REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!", - "REFERRAL_LIST_HEADER": "👥 Ваши рефералы (стр. {current}/{total})", - "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", - "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Пополнений: {count}", - "REFERRAL_LIST_ITEM_EARNED": " 💎 Заработано с него: {amount}", - "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад", - "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад", - "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно", - "REFERRAL_LIST_PREV_PAGE": "⬅️ Назад", - "REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️", - "REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов", - "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Доходы по периодам:", - "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Сегодня: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• За неделю: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• За месяц: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• За квартал: {amount}", - "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Топ-{count} рефералов:", - "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)", - "REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!", - "REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!", - "REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!", - "REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение", - "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру", - "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита", - "REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:", - "REFERRAL_SHARE_BUTTON": "📤 Поделиться", - "REFERRAL_INVITE_CREATED_TITLE": "📝 Приглашение создано!", - "REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:", - "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Способы пополнения баланса\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:", - "PAYMENT_METHODS_TITLE": "💳 Способы пополнения баланса", - "PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:", - "PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:", - "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", - "PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно", - "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банковская карта", - "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", - "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банковская карта", - "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", - "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банковская карта (Mulen Pay)", - "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay", - "PAYMENT_METHOD_PAL24_NAME": "💳 Банковская карта (PayPalych)", - "PAYMENT_METHOD_PAL24_DESCRIPTION": "через PayPalych", - "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", - "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", - "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", - "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", - "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!", - "TRIAL_INACTIVE_24H": "⏳ Прошли сутки с начала теста\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!", - "SUBSCRIPTION_EXPIRED_1D": "⛔ Подписка закончилась\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}", - "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.", - "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", - "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", - "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", - "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", - "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", - "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", - "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", - "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", - "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", - "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):", - "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):", - "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):", - "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):" + "ACCESS_DENIED": "❌ Доступ запрещен", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Недостаточно средств\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.", + "ADD_COUNTRIES_BUTTON": "🌐 Добавить страны", + "ADMIN_CAMPAIGNS": "📣 Рекламные кампании", + "ADMIN_MAIN_MENU": "🏠 Главное меню", + "ADMIN_MESSAGES": "📨 Рассылки", + "ADMIN_MONITORING": "🔍 Мониторинг", + "ADMIN_MONITORING_SETTINGS": "⚙️ Настройки мониторинга", + "ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n", + "ADMIN_PROMOCODES": "🎫 Промокоды", + "ADMIN_PROMO_GROUPS": "💳 Промогруппы", + "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", + "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", + "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", + "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", + "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Скидки на докупку доп. услуг: отключены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Скидки на докупку доп. услуг: включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED_VALUE": "отключены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED_VALUE": "включены", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽", + "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", + "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", + "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT": "Включать скидки на докупку доп. услуг при действующих скидках? (да/нет)", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", + "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:", + "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", + "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", + "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", + "ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.", + "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить", + "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.", + "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", + "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", + "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", + "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}", + "ADMIN_PROMO_GROUP_EDIT_ADDON_DISCOUNT_PROMPT": "Включать скидки на докупку доп. услуг? Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100). Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS": "🛒 Скидки на доп. услуги", + "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", + "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", + "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название", + "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", + "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", + "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик", + "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:", + "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»", + "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", + "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100). Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100). Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Введите «да» или «нет».", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.", + "ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.", + "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", + "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", + "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", + "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", + "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", + "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", + "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", + "ADMIN_REFERRALS": "🤝 Партнерка", + "ADMIN_REMNAWAVE": "🖥️ Remnawave", + "ADMIN_REPORTS": "📊 Отчеты", + "ADMIN_RULES": "📋 Правила", + "ADMIN_STATISTICS": "📊 Статистика", + "ADMIN_SUBSCRIPTIONS": "📱 Подписки", + "ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:", + "ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:", + "ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!", + "ADMIN_USERS": "👥 Пользователи", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_LINE": "Скидки на доп. услуги при докупке: {status}", + "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE": "Скидки на доп. услуги при докупке: —", + "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.", + "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", + "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа", + "ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}", + "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%, докупка: {addons}", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.", + "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.", + "ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:", + "ADMIN_USER_PROMO_GROUP_TITLE": "👥 Промогруппа пользователя", + "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»", + "ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", + "ATTACHMENTS_SENT": "✅ Вложения отправлены.", + "AUTOPAY_BUTTON": "💳 Автоплатёж", + "AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!", + "AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически", + "AUTOPAY_FAILED": "\n❌ Ошибка автоплатежа\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n", + "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни", + "AUTOPAY_SUCCESS": "\n✅ Автоплатеж выполнен\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n", + "BACK": "⬅️ Назад", + "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню", + "BACK_TO_MENU": "🏠 В главное меню", + "BACK_TO_SUBSCRIPTION": "⬅️ К подписке", + "BACK_TO_SUPPORT": "⬅️ К поддержке", + "BACK_TO_TICKETS": "⬅️ К тикетам", + "BALANCE_BUTTON": "💰 Баланс: {balance}", + "BALANCE_BUTTON_DEFAULT": "💰 Баланс: {balance}", + "BALANCE_BUTTON_ZERO": "💰 Баланс: 0 ₽", + "BALANCE_HISTORY": "📊 История операций", + "BALANCE_INFO": "\n💰 Баланс: {balance}\n\nВыберите действие:\n", + "BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку", + "BALANCE_TOPUP": "💳 Пополнить баланс", + "BALANCE_TOP_UP": "💳 Пополнить", + "BLOCK_BY_TIME": "⏳ Блокировка по времени", + "BLOCK_FOREVER": "🚫 Заблокировать", + "BUY_SUBSCRIPTION_START": "\n💎 Настройка подписки\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n", + "CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!", + "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!", + "CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.", + "CANCEL": "❌ Отмена", + "CANCEL_REPLY": "❌ Отменить ответ", + "CANCEL_TICKET_CREATION": "❌ Отменить создание тикета", + "CHANGE_DEVICES_BUTTON": "📱 Изменить устройства", + "CHANGE_DEVICES_CONFIRM": "\n 📱 Подтверждение изменения\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ", + "CHANGE_DEVICES_INFO": "\n 📱 Изменение количества устройств\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 Важно:\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ", + "CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ", + "CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ", + "CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств", + "CHANNEL_CHECK_BUTTON": "✅ Я подписался", + "CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.", + "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться", + "CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!", + "CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку", + "CHECK_STATUS_BUTTON": "📊 Проверить статус", + "CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство", + "CLOSED_TICKETS": "🟢 Закрытые", + "CLOSED_TICKETS_HEADER": "🟢 Закрытые тикеты", + "CLOSE_NOTIFICATION": "❌ Закрыть уведомление", + "CLOSE_TICKET": "🔒 Закрыть тикет", + "CONFIRM": "✅ Подтвердить", + "CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение", + "CONNECT_BUTTON": "🔗 Подключиться", + "CONTACT_SUPPORT": "💬 Написать в поддержку", + "CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой", + "CONTINUE": "➡️ Продолжить", + "CONTINUE_BUTTON": "✅ Продолжить", + "COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки", + "CREATE_INVITE": "📝 Создать приглашение", + "CREATE_INVITE_BUTTON": "📝 Создать приглашение", + "CREATE_TICKET_BUTTON": "🎫 Создать тикет", + "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена", + "DELETE_MESSAGE": "🗑 Удалить", + "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}", + "DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})", + "DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}", + "DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось", + "DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?", + "DEVICE_GUIDE_ANDROID": "🤖 Android", + "DEVICE_GUIDE_ANDROID_TV": "📺 Android TV", + "DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)", + "DEVICE_GUIDE_MAC": "🎯 macOS", + "DEVICE_GUIDE_WINDOWS": "💻 Windows", + "DISABLE_BUTTON": "❌ Выключить", + "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", + "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + "ENABLE_BUTTON": "✅ Включить", + "ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):", + "ERROR": "❌ Произошла ошибка", + "ERROR_RULES_RETRY": "Произошла ошибка. Попробуйте принять правила еще раз:", + "ERROR_TRY_AGAIN": "❌ Произошла ошибка. Попробуйте еще раз.", + "GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", + "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_PC": "💻 ПК", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n Пополните баланс на {amount} и попробуйте снова.\n ", + "INVALID_AMOUNT": "❌ Неверная сумма", + "LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: Русский", + "LOADING": "⏳ Загрузка...", + "MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n", + "MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n", + "MAIN_MENU": "👤 {user_name}\n \n📱 Подписка: {subscription_status}\n\nВыберите действие:\n", + "MAIN_MENU_ACTION_PROMPT": "Выберите действие:", + "MAIN_MENU_BUTTON": "🏠 Главное меню", + "MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами", + "MARK_AS_ANSWERED": "✅ Отметить как отвеченный", + "MENU_ADMIN": "⚙️ Админ-панель", + "MENU_BALANCE": "💰 Баланс", + "MENU_BUY_SUBSCRIPTION": "💎 Купить подписку", + "MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку", + "MENU_LANGUAGE": "🌐 Язык", + "MENU_PROMOCODE": "🎫 Промокод", + "MENU_REFERRALS": "🤝 Партнерка", + "MENU_RULES": "📋 Правила сервиса", + "MENU_SUBSCRIPTION": "📱 Подписка", + "MENU_SUPPORT": "🛠️ Техподдержка", + "MENU_TRIAL": "🧪 Тестовая подписка", + "MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.", + "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Оплата через Mulen Pay\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", + "MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay", + "MULENPAY_TOPUP_PROMPT": "💳 Оплата через Mulen Pay\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу Mulen Pay.", + "MY_BALANCE_BUTTON": "💰 Мой баланс", + "MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка", + "MY_TICKETS_BUTTON": "📋 Мои тикеты", + "MY_TICKETS_TITLE": "📋 Ваши тикеты:", + "NO": "❌ Нет", + "NOTIFICATION_CLOSED": "Уведомление закрыто.", + "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", + "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", + "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", + "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):", + "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):", + "NO_ATTACHMENTS": "Вложений нет.", + "NO_SERVERS_AVAILABLE": "❌ Нет доступных серверов", + "NO_TICKETS": "У вас пока нет тикетов.", + "NO_TICKETS_ADMIN": "Нет тикетов для отображения.", + "NO_TRAFFIC_PACKAGES": "❌ Нет доступных пакетов", + "OPEN_TICKETS": "🔴 Открытые", + "OPEN_TICKETS_HEADER": "🔴 Открытые тикеты", + "OPERATION_CANCELLED": "❌ Операция отменена", + "OTHER_APPS_BUTTON": "📋 Другие приложения", + "PAGINATION_NEXT": "➡️", + "PAGINATION_PREV": "⬅️", + "PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + "PAL24_PAYMENT_INSTRUCTIONS": "💳 Оплата через PayPalych\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через PayPalych’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", + "PAL24_PAY_BUTTON": "💳 Оплатить через PayPalych", + "PAL24_TOPUP_PROMPT": "💳 Оплата через PayPalych\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через защищенную платформу PayPalych.", + "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны", + "PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)", + "PAYMENT_CARD_PAL24": "💳 Банковская карта (PayPalych)", + "PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)", + "PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)", + "PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)", + "PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:", + "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Способы пополнения баланса\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:", + "PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:", + "PAYMENT_METHODS_TITLE": "💳 Способы пополнения баланса", + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", + "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", + "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", + "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay", + "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банковская карта (Mulen Pay)", + "PAYMENT_METHOD_PAL24_DESCRIPTION": "через PayPalych", + "PAYMENT_METHOD_PAL24_NAME": "💳 Банковская карта (PayPalych)", + "PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно", + "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", + "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", + "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", + "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", + "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банковская карта", + "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", + "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банковская карта", + "PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)", + "PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars", + "PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку", + "PAY_NOW_BUTTON": "💳 Оплатить", + "PAY_WITH_COINS_BUTTON": "🪙 Оплатить", + "PENDING_CANCEL_BUTTON": "⌛ Отмена", + "PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}", + "PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}", + "PERIOD_30_DAYS": "📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}", + "PERIOD_360_DAYS": "📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}", + "PERIOD_60_DAYS": "📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}", + "PERIOD_90_DAYS": "📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}", + "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Подключиться бесплатно 🚀", + "PROMOCODE_EMPTY_INPUT": "❌ Введите корректный промокод", + "PROMOCODE_ENTER": "🎫 Введите промокод:", + "PROMOCODE_EXPIRED": "❌ Промокод истек", + "PROMOCODE_INVALID": "❌ Неверный промокод", + "PROMOCODE_SUCCESS": "🎉 Промокод активирован! {description}", + "PROMOCODE_USED": "❌ Промокод уже использован", + "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Скидки вашей промогруппы", + "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%", + "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%", + "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%", + "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:", + "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", + "REFERRAL_ANALYTICS_BUTTON": "📊 Аналитика", + "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Доходы по периодам:", + "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• За месяц: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• За квартал: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Сегодня: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• За неделю: {amount}", + "REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!", + "REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов", + "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)", + "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Топ-{count} рефералов:", + "REFERRAL_CODE_ACCEPTED": "✅ Реферальный код принят!", + "REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.", + "REFERRAL_CODE_INVALID": "❌ Неверный реферальный код", + "REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start", + "REFERRAL_CODE_QUESTION": "\n🤝 У вас есть реферальный код от друга?\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n", + "REFERRAL_CODE_SKIP": "⏭️ Пропустить", + "REFERRAL_CODE_TITLE": "🆔 Ваш код: {code}", + "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Доходы по типам:", + "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Бонусы за первые пополнения: {count} ({amount})", + "REFERRAL_EARNINGS_PURCHASES": "• Комиссии с покупок: {count} ({amount})", + "REFERRAL_EARNINGS_TOPUPS": "• Комиссии с пополнений: {count} ({amount})", + "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Комиссия с покупки", + "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Комиссия с пополнения", + "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение", + "REFERRAL_INFO": "\n🤝 Реферальная программа\n\n👥 Приглашено: {referrals_count} друзей\n💰 Заработано: {earned_amount}\n\n🔗 Ваша реферальная ссылка:\n{referral_link}\n\n🎫 Ваш промокод:\n{referral_code}\n\n💰 Условия:\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n", + "REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!", + "REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:", + "REFERRAL_INVITE_CREATED_TITLE": "📝 Приглашение создано!", + "REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение", + "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита", + "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру", + "REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!", + "REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:", + "REFERRAL_INVITE_MESSAGE": "\n🎯 Приглашение в VPN сервис\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n", + "REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!", + "REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}", + "REFERRAL_LINK_TITLE": "🔗 Ваша реферальная ссылка:", + "REFERRAL_LIST_BUTTON": "👥 Список рефералов", + "REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!", + "REFERRAL_LIST_HEADER": "👥 Ваши рефералы (стр. {current}/{total})", + "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад", + "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно", + "REFERRAL_LIST_ITEM_EARNED": " 💎 Заработано с него: {amount}", + "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", + "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад", + "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Пополнений: {count}", + "REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️", + "REFERRAL_LIST_PREV_PAGE": "⬅️ Назад", + "REFERRAL_PROGRAM_TITLE": "👥 Реферальная программа", + "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Последние начисления:", + "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} от {referral_name}", + "REFERRAL_REWARDS_HEADER": "🎁 Как работают награды:", + "REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: {percent}%", + "REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: {bonus}", + "REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: {bonus} при первом пополнении от {minimum}", + "REFERRAL_SHARE_BUTTON": "📤 Поделиться", + "REFERRAL_STATS_ACTIVE": "• Активных рефералов: {count}", + "REFERRAL_STATS_CONVERSION": "• Конверсия: {rate}%", + "REFERRAL_STATS_FIRST_TOPUPS": "• Сделали первое пополнение: {count}", + "REFERRAL_STATS_HEADER": "📊 Ваша статистика:", + "REFERRAL_STATS_INVITED": "• Приглашено пользователей: {count}", + "REFERRAL_STATS_MONTH_EARNED": "• За последний месяц: {amount}", + "REFERRAL_STATS_TOTAL_EARNED": "• Заработано всего: {amount}", + "REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...", + "REPLY_TO_TICKET": "💬 Ответить", + "REPORT_CLOSE": "❌ Закрыть", + "REPORT_CLOSED": "✅ Отчет закрыт.", + "REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.", + "RESET_ALL_DEVICES_BUTTON": "🔄 Сбросить все устройства", + "RESET_DEVICE_CONFIRM_BUTTON": "✅ Да, сбросить это устройство", + "RESET_TRAFFIC_BUTTON": "🔄 Сбросить трафик", + "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки", + "RULES_ACCEPT": "✅ Принимаю правила", + "RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...", + "RULES_DECLINE": "❌ Не принимаю", + "RULES_HEADER": "📋 Правила сервиса", + "RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!", + "RULES_TEXT_DEFAULT": "📋 Правила использования сервиса\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\nИспользуя сервис, вы подтверждаете согласие с этими правилами.", + "SELECT_COUNTRIES": "Выберите страны:", + "SELECT_DEVICES": "Количество устройств:", + "SELECT_PERIOD": "Выберите период:", + "SELECT_TRAFFIC": "Выберите пакет трафика:", + "SENDING_ATTACHMENTS": "📎 Отправляю вложения...", + "SEND_CONTACT_BUTTON": "📱 Отправить контакт", + "SEND_LOCATION_BUTTON": "📍 Отправить геолокацию", + "SHOW_QR_BUTTON": "📱 Показать QR код", + "SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки", + "SKIP_BUTTON": "⏭️ Пропустить", + "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.", + "STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.", + "STARS_PAYMENT_SUCCESS": "🎉 Платеж успешно обработан!\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀", + "STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.", + "STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.", + "STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.", + "STARS_PRECHECK_USER_NOT_FOUND": "Пользователь не найден. Обратитесь в поддержку.", + "SUBSCRIPTION_ACTIVE": "✅ Активна", + "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", + "SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:", + "SUBSCRIPTION_APPS_TITLE": "📱 Приложения для {device_name}", + "SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Подключенные устройства:\n", + "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", + "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", + "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", + "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", + "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Ссылка для подключения:\n{subscription_url}", + "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", + "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", + "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", + "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу", + "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 Как подключить:", + "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", + "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Шаг 2 - Добавление подписки:", + "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Шаг 3 - Подключение:", + "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", + "SUBSCRIPTION_EXPIRED": "\n❌ Подписка истекла\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Подписка закончилась\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.", + "SUBSCRIPTION_EXPIRING": "\n⚠️ Подписка истекает!\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n", + "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Подписка истекает через {days_text}!\n\nВаша платная подписка истекает {end_date}.\n\n💳 Автоплатеж: {autopay_status}\n\n{action_text}\n", + "SUBSCRIPTION_EXTEND": "💎 Продлить подписку", + "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.", + "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", + "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Открыть ссылку в Happ", + "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Подключение через Happ", + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", + "SUBSCRIPTION_INFO": "\n📱 Информация о подписке\n\n📊 Статус: {status}\n🎭 Тип: {type}\n📅 Действует до: {end_date}\n⏰ Осталось дней: {days_left}\n\n📈 Трафик: {traffic_used} / {traffic_limit}\n🌍 Серверы: {countries_count} стран\n📱 Устройства: {devices_used} / {devices_limit}\n\n💳 Автоплатеж: {autopay_status}\n", + "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", + "SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", + "SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать", + "SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение", + "SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"", + "SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку", + "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", + "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 Как использовать:", + "SUBSCRIPTION_NONE": "❌ Нет активной подписки", + "SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена", + "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", + "SUBSCRIPTION_NO_SERVERS": "Нет серверов", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", + "SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!", + "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки", + "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", + "SUBSCRIPTION_STATUS_ACTIVE": "Активна", + "SUBSCRIPTION_STATUS_EXPIRED": "Истекла", + "SUBSCRIPTION_STATUS_TRIAL": "Тестовая", + "SUBSCRIPTION_STATUS_UNKNOWN": "Неизвестно", + "SUBSCRIPTION_SUMMARY": "\n📋 Итоговая конфигурация\n\n📅 Период: {period} дней\n📈 Трафик: {traffic}\n🌍 Страны: {countries}\n📱 Устройства: {devices}\n\n💰 Итого к оплате: {total_price}\n\nПодтвердить покупку?\n", + "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} дн.", + "SUBSCRIPTION_TIME_LEFT_EXPIRED": "истёк", + "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} ч.", + "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} мин.", + "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} ГБ", + "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (безлимит) | Использовано: {used} ГБ", + "SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка", + "SUBSCRIPTION_TYPE_PAID": "Платная", + "SUBSCRIPTION_TYPE_TRIAL": "Триал", + "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 истекает через несколько минут!", + "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ истекает сегодня!", + "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ истекает завтра!", + "SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Активна\n⚠️ истекает через {days} дн.", + "SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)", + "SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!", + "SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!", + "SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}", + "SUB_STATUS_NONE": "❌ Отсутствует", + "SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)", + "SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!", + "SUB_STATUS_TRIAL_TOMORROW": "🎁 Тестовая подписка\n⚠️ истекает завтра!", + "SUCCESS": "✅ Успешно", + "SUPPORT_BUTTON": "🆘 Поддержка", + "SUPPORT_INFO": "\n🛠️ Техническая поддержка\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n", + "SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик", + "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Подтверждение переключения трафика\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n", + "SWITCH_TRAFFIC_INFO": "\n🔄 Переключение лимита трафика\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 Важно:\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n", + "SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Лимит трафика уменьшен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\nℹ️ Возврат средств не производится\n", + "SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Лимит трафика увеличен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\n💰 Списано: {amount}\n", + "SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика", + "TICKET_ATTACHMENTS": "📎 Вложения", + "TICKET_CLOSED": "✅ Тикет закрыт.", + "TICKET_CLOSE_ERROR": "❌ Ошибка при закрытии тикета.", + "TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.", + "TICKET_CREATION_CANCELLED": "Создание тикета отменено.", + "TICKET_CREATION_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.", + "TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.", + "TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото c подписью:", + "TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:", + "TICKET_NOT_FOUND": "Тикет не найден.", + "TICKET_PRIORITY_HIGH": "🟠 Высокий", + "TICKET_PRIORITY_LOW": "🟢 Низкий", + "TICKET_PRIORITY_NORMAL": "🟡 Обычный", + "TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:", + "TICKET_PRIORITY_URGENT": "🔴 Срочный", + "TICKET_REPLY_CANCELLED": "Ответ отменен.", + "TICKET_REPLY_ERROR": "❌ Произошла ошибка при отправке ответа. Попробуйте позже.", + "TICKET_REPLY_INPUT": "Введите ваш ответ:", + "TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:", + "TICKET_REPLY_SENT": "✅ Ваш ответ отправлен!", + "TICKET_REPLY_TOO_SHORT": "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:", + "TICKET_STATUS_ANSWERED": "Отвечен", + "TICKET_STATUS_CLOSED": "Закрыт", + "TICKET_STATUS_OPEN": "Открыт", + "TICKET_STATUS_PENDING": "В ожидании", + "TICKET_TITLE_INPUT": "Введите заголовок тикета:", + "TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:", + "TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:", + "TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.", + "TOPUP_BALANCE_BUTTON": "💳 Попол\\у043Dить баланс", + "TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):", + "TOP_UP_METHODS": "\n💳 Выберите способ оплаты\n\nСумма: {amount}\n", + "TOP_UP_STARS": "⭐ Telegram Stars", + "TOP_UP_TRIBUTE": "💎 Банковская карта", + "TRAFFIC_100GB": "📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", + "TRAFFIC_10GB": "📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", + "TRAFFIC_250GB": "📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", + "TRAFFIC_25GB": "📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}", + "TRAFFIC_50GB": "📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}", + "TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", + "TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}", + "TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился", + "TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены", + "TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", + "TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!", + "TRIAL_ACTIVATE_BUTTON": "🎁 Активировать", + "TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована", + "TRIAL_AVAILABLE": "\n🎁 Тестовая подписка\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ Период: {days} дней\n📈 Трафик: {traffic} ГБ\n📱 Устройства: {devices} шт.\n🌍 Сервер: {server_name}\n\nАктивировать тестовую подписку?\n", + "TRIAL_ENDING_SOON": "\n🎁 Тестовая подписка скоро закончится!\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 Не хотите остаться без VPN?\nПереходите на полную подписку!\n\n🔥 Специальное предложение:\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n", + "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!", + "TRIAL_INACTIVE_24H": "⏳ Прошли сутки с начала теста\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!", + "UNBLOCK": "✅ Разблокировать", + "UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.", + "UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.", + "USER_NOT_FOUND": "❌ Пользователь не найден", + "VIEW_TICKET": "👁️ Посмотреть тикет", + "WELCOME": "\n🎉 Добро пожаловать в VPN сервис!\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 Преимущества:\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n", + "WELCOME_FALLBACK": "Добро пожаловать, {user_name}!", + "YES": "✅ Да" } From 19804607ac8086fe55c04af065744a546eca41ad Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 13:10:03 +0300 Subject: [PATCH 113/146] Revert "Add promo group add-on discount toggle" --- app/database/crud/promo_group.py | 5 - app/database/crud/subscription.py | 22 +- app/database/models.py | 1 - app/database/universal_migration.py | 51 -- app/handlers/admin/promo_groups.py | 164 ----- app/handlers/admin/users.py | 18 +- app/localization/locales/en.json | 619 ++++++++-------- app/localization/locales/ru.json | 435 ++++++----- app/states.py | 2 - locales/en.json | 1055 +++++++++++++-------------- locales/ru.json | 1055 +++++++++++++-------------- 11 files changed, 1570 insertions(+), 1857 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 9b927f01..3bc093f2 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -60,7 +60,6 @@ async def create_promo_group( device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, - addon_discounts_enabled: bool = True, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) @@ -78,7 +77,6 @@ async def create_promo_group( period_discounts=normalized_period_discounts or None, auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, is_default=False, - addon_discounts_enabled=addon_discounts_enabled, ) db.add(promo_group) @@ -108,7 +106,6 @@ async def update_promo_group( device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, - addon_discounts_enabled: Optional[bool] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -123,8 +120,6 @@ async def update_promo_group( group.period_discounts = normalized_period_discounts or None if auto_assign_total_spent_kopeks is not None: group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) - if addon_discounts_enabled is not None: - group.addon_discounts_enabled = bool(addon_discounts_enabled) await db.commit() await db.refresh(group) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 06385da8..91b79375 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -504,26 +504,17 @@ def _get_discount_percent( category: str, *, period_days: Optional[int] = None, - for_addon: bool = False, ) -> int: - effective_group = promo_group or getattr(user, "promo_group", None) - - percent = 0 if user is not None: try: - percent = user.get_promo_discount(category, period_days) + return user.get_promo_discount(category, period_days) except AttributeError: - percent = 0 + pass - if percent == 0 and promo_group is not None: - percent = promo_group.get_discount_percent(category, period_days) + if promo_group is not None: + return promo_group.get_discount_percent(category, period_days) - if for_addon and effective_group is not None and not getattr( - effective_group, "addon_discounts_enabled", True - ): - return 0 - - return percent + return 0 async def calculate_subscription_total_cost( @@ -861,7 +852,6 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "traffic", period_days=period_hint_days, - for_addon=True, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month @@ -883,7 +873,6 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "devices", period_days=period_hint_days, - for_addon=True, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month @@ -913,7 +902,6 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "servers", period_days=period_hint_days, - for_addon=True, ) server_discount_per_month = server_price_per_month * servers_discount_percent // 100 discounted_server_per_month = server_price_per_month - server_discount_per_month diff --git a/app/database/models.py b/app/database/models.py index d33a9347..0a3ad865 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -291,7 +291,6 @@ class PromoGroup(Base): traffic_discount_percent = Column(Integer, nullable=False, default=0) device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) - addon_discounts_enabled = Column(Boolean, nullable=False, default=True) auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index c6171c72..b123c750 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -931,54 +931,6 @@ async def ensure_promo_groups_setup(): "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" ) - addon_discounts_column_exists = await check_column_exists( - "promo_groups", "addon_discounts_enabled" - ) - - if not addon_discounts_column_exists: - if db_type == "sqlite": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN addon_discounts_enabled BOOLEAN NOT NULL DEFAULT 1" - ) - ) - await conn.execute( - text( - "UPDATE promo_groups SET addon_discounts_enabled = 1 WHERE addon_discounts_enabled IS NULL" - ) - ) - elif db_type == "postgresql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN addon_discounts_enabled BOOLEAN NOT NULL DEFAULT TRUE" - ) - ) - await conn.execute( - text( - "UPDATE promo_groups SET addon_discounts_enabled = TRUE WHERE addon_discounts_enabled IS NULL" - ) - ) - elif db_type == "mysql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN addon_discounts_enabled TINYINT(1) NOT NULL DEFAULT 1" - ) - ) - await conn.execute( - text( - "UPDATE promo_groups SET addon_discounts_enabled = 1 WHERE addon_discounts_enabled IS NULL" - ) - ) - else: - logger.error( - f"Неподдерживаемый тип БД для promo_groups.addon_discounts_enabled: {db_type}" - ) - return False - - logger.info( - "Добавлена колонка promo_groups.addon_discounts_enabled" - ) - column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -2042,7 +1994,6 @@ async def check_migration_status(): "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, - "promo_groups_addon_discounts_column": False, "users_auto_promo_group_assigned_column": False, "subscription_crypto_link_column": False, } @@ -2060,7 +2011,6 @@ async def check_migration_status(): status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') - status["promo_groups_addon_discounts_column"] = await check_column_exists('promo_groups', 'addon_discounts_enabled') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') @@ -2098,7 +2048,6 @@ async def check_migration_status(): "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", - "promo_groups_addon_discounts_column": "Колонка addon_discounts_enabled у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index c8138a39..917f673f 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -39,31 +39,6 @@ def _format_discount_line(texts, group) -> str: ) -def _format_addon_status_value(texts, enabled: bool) -> str: - key = ( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED_VALUE" - if enabled - else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED_VALUE" - ) - default_text = "включены" if enabled else "отключены" - return texts.t(key, default_text) - - -def _format_addon_discount_line(texts, group) -> str: - enabled = getattr(group, "addon_discounts_enabled", True) - key = ( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED" - if enabled - else "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED" - ) - default_text = ( - "Скидки на докупку доп. услуг: включены" - if enabled - else "Скидки на докупку доп. услуг: отключены" - ) - return texts.t(key, default_text) - - def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: if not raw or not isinstance(raw, dict): return {} @@ -165,17 +140,6 @@ def _parse_period_discounts_input(value: str) -> Dict[int, int]: return discounts -def _parse_boolean_input(value: str) -> bool: - cleaned = (value or "").strip().lower() - - if cleaned in {"1", "true", "yes", "y", "да", "д", "on", "вкл", "+"}: - return True - if cleaned in {"0", "false", "no", "n", "нет", "н", "off", "выкл", "-"}: - return False - - raise ValueError("Invalid boolean input") - - async def _prompt_for_period_discounts( message: types.Message, state: FSMContext, @@ -280,27 +244,6 @@ async def _prompt_for_auto_assign_threshold( await message.answer(prompt_text) -async def _prompt_for_addon_discount_choice( - message: types.Message, - state: FSMContext, - prompt_key: str, - default_text: str, - *, - current_value: Optional[str] = None, -): - data = await state.get_data() - texts = get_texts(data.get("language", "ru")) - prompt_text = texts.t(prompt_key, default_text) - - if current_value is not None: - try: - prompt_text = prompt_text.format(current=current_value) - except KeyError: - pass - - await message.answer(prompt_text) - - def _build_edit_menu_content( texts, group: PromoGroup, @@ -314,7 +257,6 @@ def _build_edit_menu_content( lines = [ header, _format_discount_line(texts, group), - _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), ] @@ -376,15 +318,6 @@ def _build_edit_menu_content( callback_data=f"promo_group_edit_field_{group.id}_periods", ) ], - [ - types.InlineKeyboardButton( - text=texts.t( - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS", - "🛒 Скидки на доп. услуги", - ), - callback_data=f"promo_group_edit_field_{group.id}_addon", - ) - ], [ types.InlineKeyboardButton( text=texts.t( @@ -466,7 +399,6 @@ async def show_promo_groups_menu( group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), - _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", @@ -542,7 +474,6 @@ async def show_promo_group_details( "💳 Промогруппа: {name}", ).format(name=group.name), _format_discount_line(texts, group), - _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", @@ -744,39 +675,6 @@ async def process_create_group_period_discounts( return await state.update_data(new_group_period_discounts=period_discounts) - await state.set_state(AdminStates.creating_promo_group_addon_discount) - - await _prompt_for_addon_discount_choice( - message, - state, - "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT", - "Включать скидки на докупку доп. услуг при действующих скидках? (да/нет)", - ) - - -@admin_required -@error_handler -async def process_create_group_addon_discount( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - addon_enabled = _parse_boolean_input(message.text) - except ValueError: - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT", - "Введите «да» или «нет».", - ) - ) - return - - await state.update_data(new_group_addon_discounts_enabled=addon_enabled) await state.set_state(AdminStates.creating_promo_group_auto_assign) await _prompt_for_auto_assign_threshold( @@ -818,9 +716,6 @@ async def process_create_group_auto_assign( device_discount_percent=data["new_group_devices"], period_discounts=data.get("new_group_period_discounts"), auto_assign_total_spent_kopeks=auto_assign_kopeks, - addon_discounts_enabled=data.get( - "new_group_addon_discounts_enabled", True - ), ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -931,13 +826,6 @@ async def prompt_edit_promo_group_field( "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT", "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", ).format(current=_format_period_discounts_value(current_discounts)) - elif field == "addon": - await state.set_state(AdminStates.editing_promo_group_addon_discount) - current_value = _format_addon_status_value(texts, getattr(group, "addon_discounts_enabled", True)) - prompt = texts.t( - "ADMIN_PROMO_GROUP_EDIT_ADDON_DISCOUNT_PROMPT", - "Включать скидки на докупку доп. услуг? Текущее значение: {current}.", - ).format(current=current_value) elif field == "auto": await state.set_state(AdminStates.editing_promo_group_auto_assign) prompt = texts.t( @@ -1131,50 +1019,6 @@ async def process_edit_group_period_discounts( ) -@admin_required -@error_handler -async def process_edit_group_addon_discount( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - addon_enabled = _parse_boolean_input(message.text) - except ValueError: - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT", - "Введите «да» или «нет».", - ) - ) - return - - group = await get_promo_group_by_id(db, data.get("edit_group_id")) - if not group: - await message.answer("❌ Промогруппа не найдена") - await state.clear() - return - - group = await update_promo_group( - db, - group, - addon_discounts_enabled=addon_enabled, - ) - await state.set_state(AdminStates.editing_promo_group_menu) - - await _send_edit_menu_after_update( - message, - texts, - group, - data.get("language", db_user.language), - texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), - ) - - @admin_required @error_handler async def process_edit_group_auto_assign( @@ -1391,10 +1235,6 @@ def register_handlers(dp: Dispatcher): process_create_group_period_discounts, AdminStates.creating_promo_group_period_discount, ) - dp.message.register( - process_create_group_addon_discount, - AdminStates.creating_promo_group_addon_discount, - ) dp.message.register( process_create_group_auto_assign, AdminStates.creating_promo_group_auto_assign, @@ -1417,10 +1257,6 @@ def register_handlers(dp: Dispatcher): process_edit_group_period_discounts, AdminStates.editing_promo_group_period_discount, ) - dp.message.register( - process_edit_group_addon_discount, - AdminStates.editing_promo_group_addon_discount, - ) dp.message.register( process_edit_group_auto_assign, AdminStates.editing_promo_group_auto_assign, diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index b84b392d..45fe983f 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -835,7 +835,6 @@ async def show_user_management( • Скидка на сервера: {promo_group.server_discount_percent}% • Скидка на трафик: {promo_group.traffic_discount_percent}% • Скидка на устройства: {promo_group.device_discount_percent}% -• Скидки на доп. услуги при докупке: {"включены" if getattr(promo_group, "addon_discounts_enabled", True) else "отключены"} """ else: text += "\nПромогруппа: Не назначена" @@ -864,36 +863,21 @@ async def _render_user_promo_group( if current_group: current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT.format(name=current_group.name) - addon_status = ( - texts.t("ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED_VALUE", "включены") - if getattr(current_group, "addon_discounts_enabled", True) - else texts.t("ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED_VALUE", "отключены") - ) discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS.format( servers=current_group.server_discount_percent, traffic=current_group.traffic_discount_percent, devices=current_group.device_discount_percent, - addons=addon_status, ) - addon_line = texts.t( - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_LINE", - "Скидки на доп. услуги при докупке: {status}", - ).format(status=addon_status) current_group_id = current_group.id else: current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT_NONE discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE - addon_line = texts.t( - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE", - "Скидки на доп. услуги при докупке: —", - ) current_group_id = None text = ( f"{texts.ADMIN_USER_PROMO_GROUP_TITLE}\n\n" f"{current_line}\n" - f"{discount_line}\n" - f"{addon_line}\n\n" + f"{discount_line}\n\n" f"{texts.ADMIN_USER_PROMO_GROUP_SELECT}" ) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 6977ac1b..98b41a5b 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1,99 +1,14 @@ { - "ACCESS_DENIED": "❌ Access denied", - "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Insufficient funds\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.", "ADD_COUNTRIES_BUTTON": "🌐 Add countries", - "ADMIN_CAMPAIGNS": "📣 Promotional campaigns", "ADMIN_MAIN_MENU": "🏠 Main menu", - "ADMIN_MESSAGES": "📨 Broadcasts", - "ADMIN_MONITORING": "🔍 Monitoring", - "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", - "ADMIN_PROMOCODES": "🎫 Promo codes", - "ADMIN_PROMO_GROUPS": "💳 Promo groups", - "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", - "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", - "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", - "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", - "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Add-on purchase discounts: disabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Add-on purchase discounts: enabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED_VALUE": "disabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED_VALUE": "enabled", - "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", - "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", - "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT": "Enable discounts for add-on purchases when base discounts are set? (yes/no)", - "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:", - "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", - "ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.", - "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete", - "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.", - "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", - "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", - "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", - "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}", - "ADMIN_PROMO_GROUP_EDIT_ADDON_DISCOUNT_PROMPT": "Enable discounts for add-on purchases? Current value: {current}.", - "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS": "🛒 Add-on purchase discounts", - "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", - "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Please enter 'yes' or 'no'.", - "ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.", - "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", - "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", - "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", - "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", - "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", - "ADMIN_REFERRALS": "🤝 Referral program", - "ADMIN_REMNAWAVE": "🖥️ Remnawave", - "ADMIN_RULES": "📋 Rules", - "ADMIN_STATISTICS": "📊 Statistics", - "ADMIN_SUBSCRIPTIONS": "📱 Subscriptions", - "ADMIN_USERS": "👥 Users", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_LINE": "Add-on purchase discounts: {status}", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE": "Add-on purchase discounts: —", - "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.", - "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user", - "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group", - "ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}", - "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned", - "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%, add-ons: {addons}", - "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.", - "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.", - "ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:", - "ADMIN_USER_PROMO_GROUP_TITLE": "👥 User promo group", - "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”", - "ALREADY_REGISTERED_REFERRAL": "ℹ️ You are already registered. A referral link cannot be applied.", + "ADMIN_CAMPAIGNS": "📣 Promotional campaigns", "AUTOPAY_BUTTON": "💳 Auto payment", - "AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!", - "AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically", - "AUTOPAY_FAILED": "\n❌ Autopay failed\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n", "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days", - "AUTOPAY_SUCCESS": "\n✅ Autopay completed\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n", "BACK": "⬅️ Back", - "BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu", "BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription", - "BALANCE_BUTTON": "💰 Balance: {balance}", "BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}", - "BALANCE_BUTTON_ZERO": "💰 Balance: 0 ₽", - "BALANCE_HISTORY": "📊 Transaction history", - "BALANCE_INFO": "\n💰 Balance: {balance}\n\nChoose an action:\n", - "BALANCE_SUPPORT_REQUEST": "🛠️ Request via support", - "BALANCE_TOP_UP": "💳 Top up", - "BUY_SUBSCRIPTION_START": "\n💎 Subscription setup\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n", - "CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!", - "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!", - "CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.", "CANCEL": "❌ Cancel", "CHANGE_DEVICES_BUTTON": "📱 Change devices", - "CHANGE_DEVICES_CONFIRM": "\n📱 Confirm change\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", - "CHANGE_DEVICES_INFO": "\n📱 Adjust device limit\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 Important:\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n", - "CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n", - "CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n", - "CHANGE_DEVICES_TITLE": "📱 Change device limit", "CHANNEL_CHECK_BUTTON": "✅ I have joined", "CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.", "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe", @@ -104,17 +19,20 @@ "CONFIRM": "✅ Confirm", "CONFIRM_CHANGE_BUTTON": "✅ Confirm change", "CONNECT_BUTTON": "🔗 Connect", - "CONTACT_SUPPORT": "💬 Contact support", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ", + "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "HAPP_PLATFORM_PC": "💻 PC", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", "CONTINUE": "➡️ Continue", "CONTINUE_BUTTON": "➡️ Continue", "COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link", - "CREATE_INVITE": "📝 Create invite", "CREATE_INVITE_BUTTON": "📝 Create invite", - "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured", - "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", - "DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})", - "DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}", - "DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed", "DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?", "DEVICE_GUIDE_ANDROID": "🤖 Android", "DEVICE_GUIDE_ANDROID_TV": "📺 Android TV", @@ -124,47 +42,26 @@ "DISABLE_BUTTON": "❌ Disable", "ENABLE_BUTTON": "✅ Enable", "ERROR": "❌ An error occurred", - "ERROR_RULES_RETRY": "An error occurred. Please try accepting the rules again:", "ERROR_TRY_AGAIN": "❌ An error occurred. Please try again.", + "ERROR_RULES_RETRY": "An error occurred. Please try accepting the rules again:", "GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up", - "HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ", - "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", - "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", - "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", - "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", - "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_IOS": "🍎 iOS", - "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", - "HAPP_PLATFORM_PC": "💻 PC", - "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout", "INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.", - "INVALID_AMOUNT": "❌ Invalid amount", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Insufficient funds\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.", "LANGUAGE_SELECTED": "🌐 Interface language set: English", "LOADING": "⏳ Loading...", - "MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n", - "MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n", "MAIN_MENU": "👤 {user_name}\n\n📱 Subscription: {subscription_status}\n\nChoose an option:\n", "MAIN_MENU_ACTION_PROMPT": "Choose an option:", "MAIN_MENU_BUTTON": "🏠 Main menu", "MANAGE_DEVICES_BUTTON": "🔧 Manage devices", - "MENU_ADMIN": "⚙️ Admin panel", "MENU_BALANCE": "💰 Balance", - "MENU_BUY_SUBSCRIPTION": "💎 Buy subscription", - "MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription", - "MENU_LANGUAGE": "🌐 Language", - "MENU_PROMOCODE": "🎫 Promo code", - "MENU_REFERRALS": "🤝 Referral program", - "MENU_RULES": "📋 Service rules", - "MENU_SERVER_STATUS": "📊 Server status", "MENU_SUBSCRIPTION": "📱 Subscription", - "MENU_SUPPORT": "🛠️ Support", "MENU_TRIAL": "🎁 Trial subscription", "MY_BALANCE_BUTTON": "💰 My balance", "MY_SUBSCRIPTION_BUTTON": "📱 My subscription", "NO": "❌ No", "NO_SERVERS_AVAILABLE": "❌ No servers available", "NO_TRAFFIC_PACKAGES": "❌ No packages available", - "OPERATION_CANCELLED": "❌ Operation cancelled", "OTHER_APPS_BUTTON": "📋 Other apps", "PAGINATION_NEXT": "➡️", "PAGINATION_PREV": "⬅️", @@ -172,215 +69,33 @@ "PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)", "PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)", "PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)", - "PAYMENT_METHODS_FOOTER": "Choose a top-up method:", - "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Balance top-up methods\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:", - "PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:", - "PAYMENT_METHODS_TITLE": "💳 Balance top-up methods", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", - "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", - "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", - "PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient", - "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", - "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", - "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", - "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", - "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Bank card", - "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa", - "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Bank card", "PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)", "PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars", "PAYMENT_VIA_SUPPORT": "🛠️ Via support", "PAY_NOW_BUTTON": "💳 Pay", "PAY_WITH_COINS_BUTTON": "🪙 Pay", "PENDING_CANCEL_BUTTON": "⌛ Cancel", - "PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}", - "PERIOD_180_DAYS": "📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}", - "PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}", - "PERIOD_360_DAYS": "📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}", - "PERIOD_60_DAYS": "📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}", - "PERIOD_90_DAYS": "📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}", "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀", - "PROMOCODE_EMPTY_INPUT": "❌ Please enter a valid promo code", - "PROMOCODE_ENTER": "🎫 Enter promo code", - "PROMOCODE_EXPIRED": "❌ Promo code has expired", - "PROMOCODE_INVALID": "❌ Invalid promo code", - "PROMOCODE_SUCCESS": "🎉 Promo code applied!", - "PROMOCODE_USED": "ℹ️ Promo code has already been used", - "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Your promo group discounts", - "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%", - "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%", - "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%", - "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:", - "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", "REFERRAL_ANALYTICS_BUTTON": "📊 Analytics", - "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Earnings by period:", - "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• Month: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• Quarter: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Today: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• Week: {amount}", - "REFERRAL_ANALYTICS_FOOTER": "📈 Keep growing your referral network!", - "REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics", - "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} rewards)", - "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Top {count} referrals:", "REFERRAL_CODE_ACCEPTED": "✅ Referral code accepted!", - "REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.", "REFERRAL_CODE_INVALID": "❌ Invalid referral code", "REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.", "REFERRAL_CODE_QUESTION": "\n🤝 Do you have a friend's referral code?\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n", "REFERRAL_CODE_SKIP": "⏭️ Skip", - "REFERRAL_CODE_TITLE": "🆔 Your code: {code}", - "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Earnings by type:", - "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Bonuses for first top-ups: {count} ({amount})", - "REFERRAL_EARNINGS_PURCHASES": "• Purchase commissions: {count} ({amount})", - "REFERRAL_EARNINGS_TOPUPS": "• Top-up commissions: {count} ({amount})", - "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Purchase commission", - "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Top-up commission", - "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up", - "REFERRAL_INFO": "\n🤝 Referral program\n\n👥 Invited: {referrals_count} friends\n💰 Earned: {earned_amount}\n\n🔗 Your referral link:\n{referral_link}\n\n🎫 Your promo code:\n{referral_code}\n\n💰 Terms:\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n", - "REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!", - "REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:", - "REFERRAL_INVITE_CREATED_TITLE": "📝 Invitation created!", - "REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection", - "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection", - "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Servers worldwide", - "REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!", - "REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:", - "REFERRAL_INVITE_MESSAGE": "\n🎯 Invitation to the VPN service\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n", - "REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!", - "REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}", - "REFERRAL_LINK_TITLE": "🔗 Your referral link:", + "ALREADY_REGISTERED_REFERRAL": "ℹ️ You are already registered. A referral link cannot be applied.", "REFERRAL_LIST_BUTTON": "👥 Referral list", - "REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!", - "REFERRAL_LIST_HEADER": "👥 Your referrals (page {current}/{total})", - "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago", - "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago", - "REFERRAL_LIST_ITEM_EARNED": " 💎 Earned from them: {amount}", - "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", - "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago", - "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Top-ups: {count}", - "REFERRAL_LIST_NEXT_PAGE": "Next ➡️", - "REFERRAL_LIST_PREV_PAGE": "⬅️ Back", - "REFERRAL_PROGRAM_TITLE": "👥 Referral program", - "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Latest rewards:", - "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} from {referral_name}", - "REFERRAL_REWARDS_HEADER": "🎁 How rewards work:", - "REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: {percent}%", - "REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: {bonus}", - "REFERRAL_REWARD_NEW_USER": "• New user receives: {bonus} on the first top-up from {minimum}", - "REFERRAL_SHARE_BUTTON": "📤 Share", - "REFERRAL_STATS_ACTIVE": "• Active referrals: {count}", - "REFERRAL_STATS_CONVERSION": "• Conversion: {rate}%", - "REFERRAL_STATS_FIRST_TOPUPS": "• Made first top-up: {count}", - "REFERRAL_STATS_HEADER": "📊 Your statistics:", - "REFERRAL_STATS_INVITED": "• Invited users: {count}", - "REFERRAL_STATS_MONTH_EARNED": "• Earned last month: {amount}", - "REFERRAL_STATS_TOTAL_EARNED": "• Earned in total: {amount}", - "REGISTRATION_COMPLETING": "✅ Completing registration...", "RESET_ALL_DEVICES_BUTTON": "🔄 Reset all devices", "RESET_DEVICE_CONFIRM_BUTTON": "✅ Reset this device", "RESET_TRAFFIC_BUTTON": "🔄 Reset traffic", - "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout", - "RULES_ACCEPT": "✅ I accept the rules", - "RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...", - "RULES_DECLINE": "❌ I do not accept", "RULES_HEADER": "📋 Service Rules", - "RULES_REQUIRED": "❗️ You must accept the rules to use the service!", + "RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...", "RULES_TEXT_DEFAULT": "📋 Service Usage Rules\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\nBy using the service you agree to follow these rules.", - "SELECT_COUNTRIES": "Select countries:", - "SELECT_DEVICES": "Number of devices:", - "SELECT_PERIOD": "Choose period:", - "SELECT_TRAFFIC": "Choose traffic package:", "SEND_CONTACT_BUTTON": "📱 Share contact", "SEND_LOCATION_BUTTON": "📍 Share location", - "SERVER_STATUS_AVAILABLE": "✅ Online", - "SERVER_STATUS_ERROR_SHORT": "Failed to fetch data", - "SERVER_STATUS_LATENCY": "{latency} ms", - "SERVER_STATUS_LATENCY_UNKNOWN": "no data", - "SERVER_STATUS_NEXT_PAGE": "Next ➡️", - "SERVER_STATUS_NOT_CONFIGURED": "Feature is not available.", - "SERVER_STATUS_NO_SERVERS": "No server data available.", - "SERVER_STATUS_OFFLINE": "no response", - "SERVER_STATUS_PAGINATION": "Page {current} of {total}", - "SERVER_STATUS_PREV_PAGE": "⬅️ Back", - "SERVER_STATUS_REFRESH": "🔄 Refresh", - "SERVER_STATUS_SUMMARY": "Total servers: {total} (online: {online}, offline: {offline})", - "SERVER_STATUS_TITLE": "📊 Server status", - "SERVER_STATUS_UNAVAILABLE": "❌ Offline", - "SERVER_STATUS_UPDATED_AT": "⏱ Updated at: {time}", "SHOW_QR_BUTTON": "📱 Show QR code", "SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link", "SKIP_BUTTON": "Skip ➡️", - "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.", - "STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.", - "STARS_PAYMENT_SUCCESS": "🎉 Payment processed successfully!\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀", - "STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.", - "STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.", - "STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.", - "STARS_PRECHECK_USER_NOT_FOUND": "User not found. Please contact support.", - "SUBSCRIPTION_ACTIVE": "✅ Active", - "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", - "SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:", - "SUBSCRIPTION_APPS_TITLE": "📱 Apps for {device_name}", - "SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found", - "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", - "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Connected devices:\n", - "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", - "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", - "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", - "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app", - "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Connection link:\n{subscription_url}", - "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", - "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", - "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", - "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server", - "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 How to connect:", - "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", - "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Step 2 - Add subscription:", - "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Step 3 - Connect:", - "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", - "SUBSCRIPTION_EXPIRED": "\n❌ Subscription expired\n\nYour subscription has ended. Renew it to restore access.\n", - "SUBSCRIPTION_EXPIRING": "\n⚠️ Subscription expiring!\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n", - "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Subscription expires in {days_text}!\n\nYour paid subscription ends on {end_date}.\n\n💳 Autopay: {autopay_status}\n\n{action_text}\n", - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device", - "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Your import link for the VPN app:\n{subscription_url}", - "SUBSCRIPTION_INFO": "\n📱 Subscription details\n\n📊 Status: {status}\n🎭 Type: {type}\n📅 Valid until: {end_date}\n⏰ Days left: {days_left}\n\n📈 Traffic: {traffic_used} / {traffic_limit}\n🌍 Servers: {countries_count} countries\n📱 Devices: {devices_used} / {devices_limit}\n\n💳 Autopay: {autopay_status}\n", - "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.", - "SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.", - "SUBSCRIPTION_LINK_STEP1": "1. Tap the link above to copy it", - "SUBSCRIPTION_LINK_STEP2": "2. Open your VPN app", - "SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option", - "SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link", - "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", - "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 How to use:", - "SUBSCRIPTION_NONE": "❌ No active subscription", - "SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found", - "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", - "SUBSCRIPTION_NO_SERVERS": "No servers", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", - "SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!", "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings", - "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", - "SUBSCRIPTION_STATUS_ACTIVE": "Active", - "SUBSCRIPTION_STATUS_EXPIRED": "Expired", - "SUBSCRIPTION_STATUS_TRIAL": "Trial", - "SUBSCRIPTION_STATUS_UNKNOWN": "Unknown", - "SUBSCRIPTION_SUMMARY": "\n📋 Final configuration\n\n📅 Period: {period} days\n📈 Traffic: {traffic}\n🌍 Countries: {countries}\n📱 Devices: {devices}\n\n💰 Total: {total_price}\n\nConfirm the purchase?\n", - "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} days", - "SUBSCRIPTION_TIME_LEFT_EXPIRED": "expired", - "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} hr", - "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} min", - "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} GB", - "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (unlimited) | Used: {used} GB", - "SUBSCRIPTION_TRIAL": "🧪 Trial subscription", - "SUBSCRIPTION_TYPE_PAID": "Paid", - "SUBSCRIPTION_TYPE_TRIAL": "Trial", - "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 expires in a few minutes!", - "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ expires today!", - "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ expires tomorrow!", "SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days", "SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)", "SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!", @@ -390,38 +105,314 @@ "SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)", "SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!", "SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!", + "SUBSCRIPTION_ACTIVE": "✅ Active", "SUCCESS": "✅ Success", - "SUPPORT_INFO": "\n🛠️ Technical support\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n", + "REGISTRATION_COMPLETING": "✅ Completing registration...", "SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic", + "TOPUP_BALANCE_BUTTON": "💳 Top up balance", + "TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured", + "TRIAL_ACTIVATE_BUTTON": "🎁 Activate", + "PROMOCODE_EMPTY_INPUT": "❌ Please enter a valid promo code", + "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.", + "STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.", + "STARS_PAYMENT_SUCCESS": "🎉 Payment processed successfully!\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀", + "STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.", + "STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.", + "STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.", + "STARS_PRECHECK_USER_NOT_FOUND": "User not found. Please contact support.", + "UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.", + "UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.", + "WELCOME": "\n🎉 Welcome to VPN Service!\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 Advantages:\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n", + "WELCOME_FALLBACK": "Welcome, {user_name}!", + "YES": "✅ Yes", + "ACCESS_DENIED": "❌ Access denied", + "ADMIN_MESSAGES": "📨 Broadcasts", + "ADMIN_MONITORING": "🔍 Monitoring", + "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", + "ADMIN_PROMOCODES": "🎫 Promo codes", + "ADMIN_REFERRALS": "🤝 Referral program", + "ADMIN_REMNAWAVE": "🖥️ Remnawave", + "ADMIN_RULES": "📋 Rules", + "ADMIN_STATISTICS": "📊 Statistics", + "ADMIN_PROMO_GROUPS": "💳 Promo groups", + "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", + "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", + "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", + "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", + "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", + "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group", + "ADMIN_USER_PROMO_GROUP_TITLE": "👥 User promo group", + "ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}", + "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.", + "ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:", + "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”", + "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.", + "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.", + "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user", + "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}", + "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", + "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", + "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", + "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", + "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete", + "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:", + "ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.", + "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", + "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", + "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", + "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", + "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", + "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", + "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", + "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", + "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", + "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.", + "ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.", + "ADMIN_SUBSCRIPTIONS": "📱 Subscriptions", + "ADMIN_USERS": "👥 Users", + "AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!", + "AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically", + "AUTOPAY_FAILED": "\n❌ Autopay failed\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n", + "AUTOPAY_SUCCESS": "\n✅ Autopay completed\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n", + "BALANCE_BUTTON": "💰 Balance: {balance}", + "BALANCE_BUTTON_ZERO": "💰 Balance: 0 ₽", + "BALANCE_HISTORY": "📊 Transaction history", + "BALANCE_INFO": "\n💰 Balance: {balance}\n\nChoose an action:\n", + "BALANCE_SUPPORT_REQUEST": "🛠️ Request via support", + "BALANCE_TOP_UP": "💳 Top up", + "CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.", + "CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!", + "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!", + "BUY_SUBSCRIPTION_START": "\n💎 Subscription setup\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n", + "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Your promo group discounts", + "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%", + "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%", + "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%", + "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:", + "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", + "CHANGE_DEVICES_CONFIRM": "\n📱 Confirm change\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", + "CHANGE_DEVICES_INFO": "\n📱 Adjust device limit\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 Important:\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n", + "CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n", + "CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n", + "CHANGE_DEVICES_TITLE": "📱 Change device limit", + "CONTACT_SUPPORT": "💬 Contact support", + "CREATE_INVITE": "📝 Create invite", + "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", + "DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})", + "DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}", + "DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed", + "INVALID_AMOUNT": "❌ Invalid amount", + "MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n", + "MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n", + "MENU_ADMIN": "⚙️ Admin panel", + "MENU_BUY_SUBSCRIPTION": "💎 Buy subscription", + "MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription", + "MENU_PROMOCODE": "🎫 Promo code", + "MENU_REFERRALS": "🤝 Referral program", + "MENU_RULES": "📋 Service rules", + "MENU_SERVER_STATUS": "📊 Server status", + "MENU_SUPPORT": "🛠️ Support", + "OPERATION_CANCELLED": "❌ Operation cancelled", + "PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}", + "PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}", + "PERIOD_60_DAYS": "📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}", + "PERIOD_90_DAYS": "📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}", + "PERIOD_180_DAYS": "📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}", + "PERIOD_360_DAYS": "📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}", + "PROMOCODE_ENTER": "🎫 Enter promo code", + "PROMOCODE_EXPIRED": "❌ Promo code has expired", + "PROMOCODE_INVALID": "❌ Invalid promo code", + "PROMOCODE_SUCCESS": "🎉 Promo code applied!", + "PROMOCODE_USED": "ℹ️ Promo code has already been used", + "REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.", + "REFERRAL_INFO": "\n🤝 Referral program\n\n👥 Invited: {referrals_count} friends\n💰 Earned: {earned_amount}\n\n🔗 Your referral link:\n{referral_link}\n\n🎫 Your promo code:\n{referral_code}\n\n💰 Terms:\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n", + "REFERRAL_INVITE_MESSAGE": "\n🎯 Invitation to the VPN service\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n", + "RULES_ACCEPT": "✅ I accept the rules", + "RULES_DECLINE": "❌ I do not accept", + "RULES_REQUIRED": "❗️ You must accept the rules to use the service!", + "SELECT_COUNTRIES": "Select countries:", + "SELECT_DEVICES": "Number of devices:", + "SELECT_PERIOD": "Choose period:", + "SELECT_TRAFFIC": "Choose traffic package:", + "SUBSCRIPTION_EXPIRED": "\n❌ Subscription expired\n\nYour subscription has ended. Renew it to restore access.\n", + "SUBSCRIPTION_EXPIRING": "\n⚠️ Subscription expiring!\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n", + "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Subscription expires in {days_text}!\n\nYour paid subscription ends on {end_date}.\n\n💳 Autopay: {autopay_status}\n\n{action_text}\n", + "SUBSCRIPTION_INFO": "\n📱 Subscription details\n\n📊 Status: {status}\n🎭 Type: {type}\n📅 Valid until: {end_date}\n⏰ Days left: {days_left}\n\n📈 Traffic: {traffic_used} / {traffic_limit}\n🌍 Servers: {countries_count} countries\n📱 Devices: {devices_used} / {devices_limit}\n\n💳 Autopay: {autopay_status}\n", + "SUBSCRIPTION_NONE": "❌ No active subscription", + "SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found", + "SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!", + "SUBSCRIPTION_SUMMARY": "\n📋 Final configuration\n\n📅 Period: {period} days\n📈 Traffic: {traffic}\n🌍 Countries: {countries}\n📱 Devices: {devices}\n\n💰 Total: {total_price}\n\nConfirm the purchase?\n", + "SUBSCRIPTION_TRIAL": "🧪 Trial subscription", + "SUPPORT_INFO": "\n🛠️ Technical support\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n", + "SERVER_STATUS_AVAILABLE": "✅ Online", + "SERVER_STATUS_ERROR_SHORT": "Failed to fetch data", + "SERVER_STATUS_LATENCY": "{latency} ms", + "SERVER_STATUS_LATENCY_UNKNOWN": "no data", + "SERVER_STATUS_NEXT_PAGE": "Next ➡️", + "SERVER_STATUS_NO_SERVERS": "No server data available.", + "SERVER_STATUS_NOT_CONFIGURED": "Feature is not available.", + "SERVER_STATUS_OFFLINE": "no response", + "SERVER_STATUS_PAGINATION": "Page {current} of {total}", + "SERVER_STATUS_PREV_PAGE": "⬅️ Back", + "SERVER_STATUS_REFRESH": "🔄 Refresh", + "SERVER_STATUS_SUMMARY": "Total servers: {total} (online: {online}, offline: {offline})", + "SERVER_STATUS_TITLE": "📊 Server status", + "SERVER_STATUS_UPDATED_AT": "⏱ Updated at: {time}", + "SERVER_STATUS_UNAVAILABLE": "❌ Offline", "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Confirm traffic change\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", "SWITCH_TRAFFIC_INFO": "\n🔄 Switch traffic limit\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 Important:\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n", "SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Traffic limit decreased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\nℹ️ Payments are not refunded\n", "SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Traffic limit increased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\n💰 Charged: {amount}\n", "SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit", - "TOPUP_BALANCE_BUTTON": "💳 Top up balance", "TOP_UP_AMOUNT": "💳 Enter top-up amount (in rubles):", "TOP_UP_METHODS": "\n💳 Select a payment method\n\nAmount: {amount}\n", "TOP_UP_STARS": "⭐ Telegram Stars", "TOP_UP_TRIBUTE": "💎 Bank card", - "TRAFFIC_100GB": "📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", + "TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", "TRAFFIC_10GB": "📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", - "TRAFFIC_250GB": "📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", "TRAFFIC_25GB": "📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}", "TRAFFIC_50GB": "📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}", - "TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", + "TRAFFIC_100GB": "📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", + "TRAFFIC_250GB": "📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", + "TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", "TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", "TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed", - "TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured", - "TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", "TRIAL_ACTIVATED": "🎉 Trial subscription activated!", - "TRIAL_ACTIVATE_BUTTON": "🎁 Activate", "TRIAL_ALREADY_USED": "❌ The trial subscription has already been used", "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic} GB\n📱 Devices: {devices} pcs\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", "TRIAL_ENDING_SOON": "\n🎁 The trial subscription is ending soon!\n\nYour trial expires in a few hours.\n\n💎 Don't want to lose VPN access?\nSwitch to the full subscription!\n\n🔥 Special offer:\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n", - "UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.", - "UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.", "USER_NOT_FOUND": "❌ User not found", - "WELCOME": "\n🎉 Welcome to VPN Service!\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 Advantages:\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n", - "WELCOME_FALLBACK": "Welcome, {user_name}!", - "YES": "✅ Yes" + "MENU_LANGUAGE": "🌐 Language", + "SUBSCRIPTION_STATUS_EXPIRED": "Expired", + "SUBSCRIPTION_STATUS_TRIAL": "Trial", + "SUBSCRIPTION_STATUS_ACTIVE": "Active", + "SUBSCRIPTION_STATUS_UNKNOWN": "Unknown", + "SUBSCRIPTION_TIME_LEFT_EXPIRED": "expired", + "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} days", + "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} hr", + "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} min", + "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ expires tomorrow!", + "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ expires today!", + "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 expires in a few minutes!", + "SUBSCRIPTION_TYPE_TRIAL": "Trial", + "SUBSCRIPTION_TYPE_PAID": "Paid", + "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (unlimited) | Used: {used} GB", + "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} GB", + "SUBSCRIPTION_NO_SERVERS": "No servers", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Connected devices:\n", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", + "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Connection link:\n{subscription_url}", + "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app", + "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Your import link for the VPN app:\n{subscription_url}", + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device", + "BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu", + "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured", + "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.", + "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", + "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", + "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", + "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", + "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", + "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", + "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", + "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", + "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", + "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", + "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", + "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Step 2 - Add subscription:", + "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Step 3 - Connect:", + "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 How to connect:", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server", + "SUBSCRIPTION_APPS_TITLE": "📱 Apps for {device_name}", + "SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:", + "SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found", + "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", + "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", + "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 How to use:", + "SUBSCRIPTION_LINK_STEP1": "1. Tap the link above to copy it", + "SUBSCRIPTION_LINK_STEP2": "2. Open your VPN app", + "SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option", + "SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link", + "SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.", + "REFERRAL_PROGRAM_TITLE": "👥 Referral program", + "REFERRAL_STATS_HEADER": "📊 Your statistics:", + "REFERRAL_STATS_INVITED": "• Invited users: {count}", + "REFERRAL_STATS_FIRST_TOPUPS": "• Made first top-up: {count}", + "REFERRAL_STATS_ACTIVE": "• Active referrals: {count}", + "REFERRAL_STATS_CONVERSION": "• Conversion: {rate}%", + "REFERRAL_STATS_TOTAL_EARNED": "• Earned in total: {amount}", + "REFERRAL_STATS_MONTH_EARNED": "• Earned last month: {amount}", + "REFERRAL_REWARDS_HEADER": "🎁 How rewards work:", + "REFERRAL_REWARD_NEW_USER": "• New user receives: {bonus} on the first top-up from {minimum}", + "REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: {bonus}", + "REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: {percent}%", + "REFERRAL_LINK_TITLE": "🔗 Your referral link:", + "REFERRAL_CODE_TITLE": "🆔 Your code: {code}", + "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Latest rewards:", + "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up", + "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Top-up commission", + "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Purchase commission", + "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} from {referral_name}", + "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Earnings by type:", + "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Bonuses for first top-ups: {count} ({amount})", + "REFERRAL_EARNINGS_TOPUPS": "• Top-up commissions: {count} ({amount})", + "REFERRAL_EARNINGS_PURCHASES": "• Purchase commissions: {count} ({amount})", + "REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!", + "REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}", + "REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!", + "REFERRAL_LIST_HEADER": "👥 Your referrals (page {current}/{total})", + "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", + "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Top-ups: {count}", + "REFERRAL_LIST_ITEM_EARNED": " 💎 Earned from them: {amount}", + "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago", + "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago", + "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago", + "REFERRAL_LIST_PREV_PAGE": "⬅️ Back", + "REFERRAL_LIST_NEXT_PAGE": "Next ➡️", + "REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics", + "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Earnings by period:", + "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Today: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• Week: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• Month: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• Quarter: {amount}", + "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Top {count} referrals:", + "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} rewards)", + "REFERRAL_ANALYTICS_FOOTER": "📈 Keep growing your referral network!", + "REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!", + "REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!", + "REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection", + "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Servers worldwide", + "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection", + "REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:", + "REFERRAL_SHARE_BUTTON": "📤 Share", + "REFERRAL_INVITE_CREATED_TITLE": "📝 Invitation created!", + "REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:", + "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Balance top-up methods\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:", + "PAYMENT_METHODS_TITLE": "💳 Balance top-up methods", + "PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:", + "PAYMENT_METHODS_FOOTER": "Choose a top-up method:", + "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", + "PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient", + "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Bank card", + "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa", + "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Bank card", + "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", + "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", + "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", + "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", + "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance." + } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index a099b22a..831a55d1 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1,74 +1,62 @@ { "ACCESS_DENIED": "❌ Доступ запрещен", - "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Недостаточно средств\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.", "ADD_COUNTRIES_BUTTON": "🌐 Добавить страны", - "ADMIN_CAMPAIGNS": "📣 Рекламные кампании", "ADMIN_MAIN_MENU": "🏠 Главное меню", + "ADMIN_CAMPAIGNS": "📣 Рекламные кампании", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MONITORING": "🔍 Мониторинг", "ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n", "ADMIN_PROMOCODES": "🎫 Промокоды", - "ADMIN_PROMO_GROUPS": "💳 Промогруппы", - "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", - "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", - "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", - "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", - "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Скидки на докупку доп. услуг: отключены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Скидки на докупку доп. услуг: включены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED_VALUE": "отключены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED_VALUE": "включены", - "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", - "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", - "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT": "Включать скидки на докупку доп. услуг при действующих скидках? (да/нет)", - "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:", - "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", - "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", - "ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.", - "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить", - "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.", - "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", - "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", - "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", - "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}", - "ADMIN_PROMO_GROUP_EDIT_ADDON_DISCOUNT_PROMPT": "Включать скидки на докупку доп. услуг? Текущее значение: {current}.", - "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS": "🛒 Скидки на доп. услуги", - "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", - "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Введите «да» или «нет».", - "ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.", - "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", - "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", - "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", - "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", - "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_REFERRALS": "🤝 Партнерка", "ADMIN_REMNAWAVE": "🖥️ Remnawave", "ADMIN_RULES": "📋 Правила", "ADMIN_STATISTICS": "📊 Статистика", - "ADMIN_SUBSCRIPTIONS": "📱 Подписки", - "ADMIN_TICKETS_TITLE_CLOSED": "🎫 Закрытые тикеты поддержки:", - "ADMIN_TICKETS_TITLE_OPEN": "🎫 Открытые тикеты поддержки:", - "ADMIN_USERS": "👥 Пользователи", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_LINE": "Скидки на доп. услуги при докупке: {status}", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE": "Скидки на доп. услуги при докупке: —", - "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.", - "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", + "ADMIN_PROMO_GROUPS": "💳 Промогруппы", + "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", + "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", + "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", + "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", + "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа", + "ADMIN_USER_PROMO_GROUP_TITLE": "👥 Промогруппа пользователя", "ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}", "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена", - "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%, докупка: {addons}", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.", - "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.", "ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:", - "ADMIN_USER_PROMO_GROUP_TITLE": "👥 Промогруппа пользователя", "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»", - "ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", + "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.", + "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.", + "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", + "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}", + "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", + "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", + "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", + "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", + "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить", + "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:", + "ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.", + "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", + "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", + "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", + "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", + "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", + "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", + "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", + "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", + "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", + "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.", + "ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.", + "ADMIN_SUBSCRIPTIONS": "📱 Подписки", + "ADMIN_USERS": "👥 Пользователи", + "ADMIN_TICKETS_TITLE_OPEN": "🎫 Открытые тикеты поддержки:", + "ADMIN_TICKETS_TITLE_CLOSED": "🎫 Закрытые тикеты поддержки:", "AUTOPAY_BUTTON": "💳 Автоплатёж", "AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!", "AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически", @@ -76,7 +64,6 @@ "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни", "AUTOPAY_SUCCESS": "\n✅ Автоплатеж выполнен\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n", "BACK": "⬅️ Назад", - "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню", "BACK_TO_SUBSCRIPTION": "⬅️ К подписке", "BALANCE_BUTTON": "💰 Баланс: {balance}", "BALANCE_BUTTON_DEFAULT": "💰 Баланс: {balance}", @@ -85,10 +72,16 @@ "BALANCE_INFO": "\n💰 Баланс: {balance}\n\nВыберите действие:\n", "BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку", "BALANCE_TOP_UP": "💳 Пополнить", - "BUY_SUBSCRIPTION_START": "\n💎 Настройка подписки\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n", + "CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.", "CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!", "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!", - "CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.", + "BUY_SUBSCRIPTION_START": "\n💎 Настройка подписки\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n", + "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Скидки вашей промогруппы", + "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%", + "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%", + "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%", + "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:", + "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", "CANCEL": "❌ Отмена", "CHANGE_DEVICES_BUTTON": "📱 Изменить устройства", "CHANGE_DEVICES_CONFIRM": "\n 📱 Подтверждение изменения\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ", @@ -106,13 +99,22 @@ "CONFIRM": "✅ Подтвердить", "CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение", "CONNECT_BUTTON": "🔗 Подключиться", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ", + "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "HAPP_PLATFORM_PC": "💻 ПК", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", "CONTACT_SUPPORT": "💬 Написать в поддержку", "CONTINUE": "➡️ Продолжить", "CONTINUE_BUTTON": "✅ Продолжить", "COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки", "CREATE_INVITE": "📝 Создать приглашение", "CREATE_INVITE_BUTTON": "📝 Создать приглашение", - "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена", "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}", "DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})", "DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}", @@ -126,20 +128,12 @@ "DISABLE_BUTTON": "❌ Выключить", "ENABLE_BUTTON": "✅ Включить", "ERROR": "❌ Произошла ошибка", - "ERROR_RULES_RETRY": "Произошла ошибка. Попробуйте принять правила еще раз:", "ERROR_TRY_AGAIN": "❌ Произошла ошибка. Попробуйте еще раз.", + "ERROR_RULES_RETRY": "Произошла ошибка. Попробуйте принять правила еще раз:", "GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса", - "HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ", - "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", - "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", - "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", - "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", - "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_IOS": "🍎 iOS", - "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", - "HAPP_PLATFORM_PC": "💻 ПК", - "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки", "INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n Пополните баланс на {amount} и попробуйте снова.\n ", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Недостаточно средств\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.", "INVALID_AMOUNT": "❌ Неверная сумма", "LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: Русский", "LOADING": "⏳ Загрузка...", @@ -174,21 +168,6 @@ "PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)", "PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)", "PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)", - "PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:", - "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Способы пополнения баланса\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:", - "PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:", - "PAYMENT_METHODS_TITLE": "💳 Способы пополнения баланса", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", - "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", - "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", - "PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно", - "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", - "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", - "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", - "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", - "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банковская карта", - "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", - "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банковская карта", "PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)", "PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars", "PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку", @@ -202,86 +181,26 @@ "PERIOD_60_DAYS": "📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}", "PERIOD_90_DAYS": "📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}", "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Подключиться бесплатно 🚀", - "PROMOCODE_EMPTY_INPUT": "❌ Введите корректный промокод", "PROMOCODE_ENTER": "🎫 Введите промокод:", + "PROMOCODE_EMPTY_INPUT": "❌ Введите корректный промокод", "PROMOCODE_EXPIRED": "❌ Промокод истек", "PROMOCODE_INVALID": "❌ Неверный промокод", "PROMOCODE_SUCCESS": "🎉 Промокод активирован! {description}", "PROMOCODE_USED": "❌ Промокод уже использован", - "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Скидки вашей промогруппы", - "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%", - "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%", - "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%", - "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:", - "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", "REFERRAL_ANALYTICS_BUTTON": "📊 Аналитика", - "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Доходы по периодам:", - "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• За месяц: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• За квартал: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Сегодня: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• За неделю: {amount}", - "REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!", - "REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов", - "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)", - "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Топ-{count} рефералов:", - "REFERRAL_CODE_ACCEPTED": "✅ Реферальный код принят!", "REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.", + "REFERRAL_CODE_ACCEPTED": "✅ Реферальный код принят!", "REFERRAL_CODE_INVALID": "❌ Неверный реферальный код", "REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start", "REFERRAL_CODE_QUESTION": "\n🤝 У вас есть реферальный код от друга?\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n", "REFERRAL_CODE_SKIP": "⏭️ Пропустить", - "REFERRAL_CODE_TITLE": "🆔 Ваш код: {code}", - "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Доходы по типам:", - "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Бонусы за первые пополнения: {count} ({amount})", - "REFERRAL_EARNINGS_PURCHASES": "• Комиссии с покупок: {count} ({amount})", - "REFERRAL_EARNINGS_TOPUPS": "• Комиссии с пополнений: {count} ({amount})", - "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Комиссия с покупки", - "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Комиссия с пополнения", - "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение", + "ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", "REFERRAL_INFO": "\n🤝 Реферальная программа\n\n👥 Приглашено: {referrals_count} друзей\n💰 Заработано: {earned_amount}\n\n🔗 Ваша реферальная ссылка:\n{referral_link}\n\n🎫 Ваш промокод:\n{referral_code}\n\n💰 Условия:\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n", - "REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!", - "REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:", - "REFERRAL_INVITE_CREATED_TITLE": "📝 Приглашение создано!", - "REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение", - "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита", - "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру", - "REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!", - "REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:", "REFERRAL_INVITE_MESSAGE": "\n🎯 Приглашение в VPN сервис\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n", - "REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!", - "REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}", - "REFERRAL_LINK_TITLE": "🔗 Ваша реферальная ссылка:", "REFERRAL_LIST_BUTTON": "👥 Список рефералов", - "REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!", - "REFERRAL_LIST_HEADER": "👥 Ваши рефералы (стр. {current}/{total})", - "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад", - "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно", - "REFERRAL_LIST_ITEM_EARNED": " 💎 Заработано с него: {amount}", - "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", - "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад", - "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Пополнений: {count}", - "REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️", - "REFERRAL_LIST_PREV_PAGE": "⬅️ Назад", - "REFERRAL_PROGRAM_TITLE": "👥 Реферальная программа", - "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Последние начисления:", - "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} от {referral_name}", - "REFERRAL_REWARDS_HEADER": "🎁 Как работают награды:", - "REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: {percent}%", - "REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: {bonus}", - "REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: {bonus} при первом пополнении от {minimum}", - "REFERRAL_SHARE_BUTTON": "📤 Поделиться", - "REFERRAL_STATS_ACTIVE": "• Активных рефералов: {count}", - "REFERRAL_STATS_CONVERSION": "• Конверсия: {rate}%", - "REFERRAL_STATS_FIRST_TOPUPS": "• Сделали первое пополнение: {count}", - "REFERRAL_STATS_HEADER": "📊 Ваша статистика:", - "REFERRAL_STATS_INVITED": "• Приглашено пользователей: {count}", - "REFERRAL_STATS_MONTH_EARNED": "• За последний месяц: {amount}", - "REFERRAL_STATS_TOTAL_EARNED": "• Заработано всего: {amount}", - "REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...", "RESET_ALL_DEVICES_BUTTON": "🔄 Сбросить все устройства", "RESET_DEVICE_CONFIRM_BUTTON": "✅ Да, сбросить это устройство", "RESET_TRAFFIC_BUTTON": "🔄 Сбросить трафик", - "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки", "RULES_ACCEPT": "✅ Принимаю правила", "RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...", "RULES_DECLINE": "❌ Не принимаю", @@ -294,95 +213,20 @@ "SELECT_TRAFFIC": "Выберите пакет трафика:", "SEND_CONTACT_BUTTON": "📱 Отправить контакт", "SEND_LOCATION_BUTTON": "📍 Отправить геолокацию", - "SERVER_STATUS_AVAILABLE": "✅ Доступны", - "SERVER_STATUS_ERROR_SHORT": "Не удалось получить данные", - "SERVER_STATUS_LATENCY": "{latency} мс", - "SERVER_STATUS_LATENCY_UNKNOWN": "нет данных", - "SERVER_STATUS_NEXT_PAGE": "Вперед ➡️", - "SERVER_STATUS_NOT_CONFIGURED": "Функция недоступна.", - "SERVER_STATUS_NO_SERVERS": "Нет данных о серверах.", - "SERVER_STATUS_OFFLINE": "нет ответа", - "SERVER_STATUS_PAGINATION": "Страница {current} из {total}", - "SERVER_STATUS_PREV_PAGE": "⬅️ Назад", - "SERVER_STATUS_REFRESH": "🔄 Обновить", - "SERVER_STATUS_SUMMARY": "Всего серверов: {total} (в сети: {online}, вне сети: {offline})", - "SERVER_STATUS_TITLE": "📊 Статус серверов", - "SERVER_STATUS_UNAVAILABLE": "❌ Недоступны", - "SERVER_STATUS_UPDATED_AT": "⏱ Обновлено: {time}", "SHOW_QR_BUTTON": "📱 Показать QR код", "SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки", "SKIP_BUTTON": "⏭️ Пропустить", - "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.", - "STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.", - "STARS_PAYMENT_SUCCESS": "🎉 Платеж успешно обработан!\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀", - "STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.", - "STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.", - "STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.", - "STARS_PRECHECK_USER_NOT_FOUND": "Пользователь не найден. Обратитесь в поддержку.", "SUBSCRIPTION_ACTIVE": "✅ Активна", - "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", - "SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:", - "SUBSCRIPTION_APPS_TITLE": "📱 Приложения для {device_name}", - "SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено", - "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", - "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Подключенные устройства:\n", - "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", - "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", - "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", - "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", - "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Ссылка для подключения:\n{subscription_url}", - "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", - "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", - "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", - "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу", - "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 Как подключить:", - "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", - "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Шаг 2 - Добавление подписки:", - "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Шаг 3 - Подключение:", - "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", "SUBSCRIPTION_EXPIRED": "\n❌ Подписка истекла\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n", "SUBSCRIPTION_EXPIRING": "\n⚠️ Подписка истекает!\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n", "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Подписка истекает через {days_text}!\n\nВаша платная подписка истекает {end_date}.\n\n💳 Автоплатеж: {autopay_status}\n\n{action_text}\n", - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", "SUBSCRIPTION_INFO": "\n📱 Информация о подписке\n\n📊 Статус: {status}\n🎭 Тип: {type}\n📅 Действует до: {end_date}\n⏰ Осталось дней: {days_left}\n\n📈 Трафик: {traffic_used} / {traffic_limit}\n🌍 Серверы: {countries_count} стран\n📱 Устройства: {devices_used} / {devices_limit}\n\n💳 Автоплатеж: {autopay_status}\n", - "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", - "SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", - "SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать", - "SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение", - "SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"", - "SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку", - "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", - "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 Как использовать:", "SUBSCRIPTION_NONE": "❌ Нет активной подписки", "SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена", - "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", - "SUBSCRIPTION_NO_SERVERS": "Нет серверов", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", "SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!", "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки", - "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", - "SUBSCRIPTION_STATUS_ACTIVE": "Активна", - "SUBSCRIPTION_STATUS_EXPIRED": "Истекла", - "SUBSCRIPTION_STATUS_TRIAL": "Тестовая", - "SUBSCRIPTION_STATUS_UNKNOWN": "Неизвестно", "SUBSCRIPTION_SUMMARY": "\n📋 Итоговая конфигурация\n\n📅 Период: {period} дней\n📈 Трафик: {traffic}\n🌍 Страны: {countries}\n📱 Устройства: {devices}\n\n💰 Итого к оплате: {total_price}\n\nПодтвердить покупку?\n", - "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} дн.", - "SUBSCRIPTION_TIME_LEFT_EXPIRED": "истёк", - "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} ч.", - "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} мин.", - "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} ГБ", - "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (безлимит) | Использовано: {used} ГБ", "SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка", - "SUBSCRIPTION_TYPE_PAID": "Платная", - "SUBSCRIPTION_TYPE_TRIAL": "Триал", - "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 истекает через несколько минут!", - "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ истекает сегодня!", - "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ истекает завтра!", "SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Активна\n⚠️ истекает через {days} дн.", "SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)", "SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!", @@ -393,7 +237,23 @@ "SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!", "SUB_STATUS_TRIAL_TOMORROW": "🎁 Тестовая подписка\n⚠️ истекает завтра!", "SUCCESS": "✅ Успешно", + "REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...", "SUPPORT_INFO": "\n🛠️ Техническая поддержка\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n", + "SERVER_STATUS_AVAILABLE": "✅ Доступны", + "SERVER_STATUS_ERROR_SHORT": "Не удалось получить данные", + "SERVER_STATUS_LATENCY": "{latency} мс", + "SERVER_STATUS_LATENCY_UNKNOWN": "нет данных", + "SERVER_STATUS_NEXT_PAGE": "Вперед ➡️", + "SERVER_STATUS_NO_SERVERS": "Нет данных о серверах.", + "SERVER_STATUS_NOT_CONFIGURED": "Функция недоступна.", + "SERVER_STATUS_OFFLINE": "нет ответа", + "SERVER_STATUS_PAGINATION": "Страница {current} из {total}", + "SERVER_STATUS_PREV_PAGE": "⬅️ Назад", + "SERVER_STATUS_REFRESH": "🔄 Обновить", + "SERVER_STATUS_SUMMARY": "Всего серверов: {total} (в сети: {online}, вне сети: {offline})", + "SERVER_STATUS_TITLE": "📊 Статус серверов", + "SERVER_STATUS_UPDATED_AT": "⏱ Обновлено: {time}", + "SERVER_STATUS_UNAVAILABLE": "❌ Недоступны", "SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик", "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Подтверждение переключения трафика\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n", "SWITCH_TRAFFIC_INFO": "\n🔄 Переключение лимита трафика\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 Важно:\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n", @@ -404,6 +264,13 @@ "TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):", "TOP_UP_METHODS": "\n💳 Выберите способ оплаты\n\nСумма: {amount}\n", "TOP_UP_STARS": "⭐ Telegram Stars", + "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.", + "STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.", + "STARS_PAYMENT_SUCCESS": "🎉 Платеж успешно обработан!\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀", + "STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.", + "STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.", + "STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.", + "STARS_PRECHECK_USER_NOT_FOUND": "Пользователь не найден. Обратитесь в поддержку.", "TOP_UP_TRIBUTE": "💎 Банковская карта", "TRAFFIC_100GB": "📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", "TRAFFIC_10GB": "📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", @@ -425,5 +292,129 @@ "USER_NOT_FOUND": "❌ Пользователь не найден", "WELCOME": "\n🎉 Добро пожаловать в VPN сервис!\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 Преимущества:\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n", "WELCOME_FALLBACK": "Добро пожаловать, {user_name}!", - "YES": "✅ Да" + "YES": "✅ Да", + "SUBSCRIPTION_STATUS_EXPIRED": "Истекла", + "SUBSCRIPTION_STATUS_TRIAL": "Тестовая", + "SUBSCRIPTION_STATUS_ACTIVE": "Активна", + "SUBSCRIPTION_STATUS_UNKNOWN": "Неизвестно", + "SUBSCRIPTION_TIME_LEFT_EXPIRED": "истёк", + "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} дн.", + "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} ч.", + "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} мин.", + "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ истекает завтра!", + "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ истекает сегодня!", + "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 истекает через несколько минут!", + "SUBSCRIPTION_TYPE_TRIAL": "Триал", + "SUBSCRIPTION_TYPE_PAID": "Платная", + "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (безлимит) | Использовано: {used} ГБ", + "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} ГБ", + "SUBSCRIPTION_NO_SERVERS": "Нет серверов", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Подключенные устройства:\n", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", + "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Ссылка для подключения:\n{subscription_url}", + "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", + "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню", + "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена", + "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", + "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", + "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", + "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", + "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", + "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", + "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", + "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", + "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", + "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", + "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", + "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", + "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Шаг 2 - Добавление подписки:", + "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Шаг 3 - Подключение:", + "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 Как подключить:", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу", + "SUBSCRIPTION_APPS_TITLE": "📱 Приложения для {device_name}", + "SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:", + "SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено", + "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", + "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", + "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 Как использовать:", + "SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать", + "SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение", + "SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"", + "SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку", + "SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", + "REFERRAL_PROGRAM_TITLE": "👥 Реферальная программа", + "REFERRAL_STATS_HEADER": "📊 Ваша статистика:", + "REFERRAL_STATS_INVITED": "• Приглашено пользователей: {count}", + "REFERRAL_STATS_FIRST_TOPUPS": "• Сделали первое пополнение: {count}", + "REFERRAL_STATS_ACTIVE": "• Активных рефералов: {count}", + "REFERRAL_STATS_CONVERSION": "• Конверсия: {rate}%", + "REFERRAL_STATS_TOTAL_EARNED": "• Заработано всего: {amount}", + "REFERRAL_STATS_MONTH_EARNED": "• За последний месяц: {amount}", + "REFERRAL_REWARDS_HEADER": "🎁 Как работают награды:", + "REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: {bonus} при первом пополнении от {minimum}", + "REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: {bonus}", + "REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: {percent}%", + "REFERRAL_LINK_TITLE": "🔗 Ваша реферальная ссылка:", + "REFERRAL_CODE_TITLE": "🆔 Ваш код: {code}", + "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Последние начисления:", + "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение", + "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Комиссия с пополнения", + "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Комиссия с покупки", + "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} от {referral_name}", + "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Доходы по типам:", + "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Бонусы за первые пополнения: {count} ({amount})", + "REFERRAL_EARNINGS_TOPUPS": "• Комиссии с пополнений: {count} ({amount})", + "REFERRAL_EARNINGS_PURCHASES": "• Комиссии с покупок: {count} ({amount})", + "REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!", + "REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}", + "REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!", + "REFERRAL_LIST_HEADER": "👥 Ваши рефералы (стр. {current}/{total})", + "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", + "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Пополнений: {count}", + "REFERRAL_LIST_ITEM_EARNED": " 💎 Заработано с него: {amount}", + "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад", + "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад", + "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно", + "REFERRAL_LIST_PREV_PAGE": "⬅️ Назад", + "REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️", + "REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов", + "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Доходы по периодам:", + "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Сегодня: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• За неделю: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• За месяц: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• За квартал: {amount}", + "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Топ-{count} рефералов:", + "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)", + "REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!", + "REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!", + "REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!", + "REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение", + "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру", + "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита", + "REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:", + "REFERRAL_SHARE_BUTTON": "📤 Поделиться", + "REFERRAL_INVITE_CREATED_TITLE": "📝 Приглашение создано!", + "REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:", + "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Способы пополнения баланса\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:", + "PAYMENT_METHODS_TITLE": "💳 Способы пополнения баланса", + "PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:", + "PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:", + "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", + "PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно", + "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банковская карта", + "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", + "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банковская карта", + "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", + "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", + "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", + "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", + "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку." + } diff --git a/app/states.py b/app/states.py index 6bf45cf3..f824f9a5 100644 --- a/app/states.py +++ b/app/states.py @@ -69,7 +69,6 @@ class AdminStates(StatesGroup): creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() creating_promo_group_period_discount = State() - creating_promo_group_addon_discount = State() creating_promo_group_auto_assign = State() editing_promo_group_menu = State() @@ -78,7 +77,6 @@ class AdminStates(StatesGroup): editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() editing_promo_group_period_discount = State() - editing_promo_group_addon_discount = State() editing_promo_group_auto_assign = State() editing_squad_price = State() diff --git a/locales/en.json b/locales/en.json index 7b9d8a81..f217ed28 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,534 +1,525 @@ { - "ACCESS_DENIED": "❌ Access denied", - "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Insufficient funds\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.", - "ADD_COUNTRIES_BUTTON": "🌐 Add countries", - "ADMIN_CAMPAIGNS": "📣 Promotional campaigns", - "ADMIN_MAIN_MENU": "🏠 Main menu", - "ADMIN_MESSAGES": "📨 Broadcasts", - "ADMIN_MONITORING": "🔍 Monitoring", - "ADMIN_MONITORING_SETTINGS": "⚙️ Monitoring settings", - "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", - "ADMIN_PROMOCODES": "🎫 Promo codes", - "ADMIN_PROMO_GROUPS": "💳 Promo groups", - "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", - "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", - "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", - "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", - "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Add-on purchase discounts: disabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Add-on purchase discounts: enabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED_VALUE": "disabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED_VALUE": "enabled", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽", - "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", - "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", - "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT": "Enable discounts for add-on purchases when base discounts are set? (yes/no)", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", - "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:", - "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", - "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", - "ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.", - "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete", - "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.", - "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", - "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", - "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", - "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}", - "ADMIN_PROMO_GROUP_EDIT_ADDON_DISCOUNT_PROMPT": "Enable discounts for add-on purchases? Current value: {current}.", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.", - "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100). Current value: {current}.", - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS": "🛒 Add-on purchase discounts", - "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", - "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", - "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename", - "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", - "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", - "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount", - "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:", - "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”", - "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", - "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100). Current value: {current}.", - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100). Current value: {current}.", - "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Please enter 'yes' or 'no'.", - "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.", - "ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.", - "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", - "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", - "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", - "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", - "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", - "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", - "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", - "ADMIN_REFERRALS": "🤝 Referral program", - "ADMIN_REMNAWAVE": "🖥️ Remnawave", - "ADMIN_REPORTS": "📊 Reports", - "ADMIN_RULES": "📋 Rules", - "ADMIN_STATISTICS": "📊 Statistics", - "ADMIN_SUBSCRIPTIONS": "📱 Subscriptions", - "ADMIN_TICKETS_TITLE": "🎫 All support tickets:", - "ADMIN_TICKET_REPLY_INPUT": "Enter support reply:", - "ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!", - "ADMIN_USERS": "👥 Users", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_LINE": "Add-on purchase discounts: {status}", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE": "Add-on purchase discounts: —", - "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.", - "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user", - "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group", - "ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}", - "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned", - "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%, add-ons: {addons}", - "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.", - "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.", - "ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:", - "ADMIN_USER_PROMO_GROUP_TITLE": "👥 User promo group", - "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”", - "ALREADY_REGISTERED_REFERRAL": "ℹ️ You are already registered. A referral link cannot be applied.", - "ATTACHMENTS_SENT": "✅ Attachments sent.", - "AUTOPAY_BUTTON": "💳 Auto payment", - "AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!", - "AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically", - "AUTOPAY_FAILED": "\n❌ Autopay failed\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n", - "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days", - "AUTOPAY_SUCCESS": "\n✅ Autopay completed\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n", - "BACK": "⬅️ Back", - "BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu", - "BACK_TO_MENU": "🏠 Back to menu", - "BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription", - "BACK_TO_SUPPORT": "⬅️ Back to support", - "BACK_TO_TICKETS": "⬅️ Back to tickets", - "BALANCE_BUTTON": "💰 Balance: {balance}", - "BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}", - "BALANCE_BUTTON_ZERO": "💰 Balance: 0 ₽", - "BALANCE_HISTORY": "📊 Transaction history", - "BALANCE_INFO": "\n💰 Balance: {balance}\n\nChoose an action:\n", - "BALANCE_SUPPORT_REQUEST": "🛠️ Request via support", - "BALANCE_TOPUP": "💳 Top up balance", - "BALANCE_TOP_UP": "💳 Top up", - "BLOCK_BY_TIME": "⏳ Temporary block", - "BLOCK_FOREVER": "🚫 Block permanently", - "BUY_SUBSCRIPTION_START": "\n💎 Subscription setup\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n", - "CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!", - "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!", - "CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.", - "CANCEL": "❌ Cancel", - "CANCEL_REPLY": "❌ Cancel reply", - "CANCEL_TICKET_CREATION": "❌ Cancel ticket creation", - "CHANGE_DEVICES_BUTTON": "📱 Change devices", - "CHANGE_DEVICES_CONFIRM": "\n📱 Confirm change\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", - "CHANGE_DEVICES_INFO": "\n📱 Adjust device limit\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 Important:\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n", - "CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n", - "CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n", - "CHANGE_DEVICES_TITLE": "📱 Change device limit", - "CHANNEL_CHECK_BUTTON": "✅ I have joined", - "CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.", - "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe", - "CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!", - "CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing", - "CHECK_STATUS_BUTTON": "📊 Check status", - "CHOOSE_ANOTHER_DEVICE": "📱 Choose another device", - "CLOSED_TICKETS": "🟢 Closed", - "CLOSED_TICKETS_HEADER": "🟢 Closed tickets", - "CLOSE_NOTIFICATION": "❌ Close notification", - "CLOSE_TICKET": "🔒 Close ticket", - "CONFIRM": "✅ Confirm", - "CONFIRM_CHANGE_BUTTON": "✅ Confirm change", - "CONNECT_BUTTON": "🔗 Connect", - "CONTACT_SUPPORT": "💬 Contact support", - "CONTACT_SUPPORT_BUTTON": "💬 Contact support", - "CONTINUE": "➡️ Continue", - "CONTINUE_BUTTON": "➡️ Continue", - "COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link", - "CREATE_INVITE": "📝 Create invite", - "CREATE_INVITE_BUTTON": "📝 Create invite", - "CREATE_TICKET_BUTTON": "🎫 Create ticket", - "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured", - "DELETE_MESSAGE": "🗑 Delete", - "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", - "DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})", - "DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}", - "DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed", - "DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?", - "DEVICE_GUIDE_ANDROID": "🤖 Android", - "DEVICE_GUIDE_ANDROID_TV": "📺 Android TV", - "DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)", - "DEVICE_GUIDE_MAC": "🎯 macOS", - "DEVICE_GUIDE_WINDOWS": "💻 Windows", - "DISABLE_BUTTON": "❌ Disable", - "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", - "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.", - "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.", - "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.", - "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.", - "ENABLE_BUTTON": "✅ Enable", - "ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):", - "ERROR": "❌ An error occurred", - "ERROR_RULES_RETRY": "An error occurred. Please try accepting the rules again:", - "ERROR_TRY_AGAIN": "❌ An error occurred. Please try again.", - "GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up", - "HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ", - "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", - "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", - "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", - "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", - "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_IOS": "🍎 iOS", - "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", - "HAPP_PLATFORM_PC": "💻 PC", - "HAPP_PLATFORM_WINDOWS": "💻 Windows", - "INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.", - "INVALID_AMOUNT": "❌ Invalid amount", - "LANGUAGE_SELECTED": "🌐 Interface language set: English", - "LOADING": "⏳ Loading...", - "MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n", - "MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n", - "MAIN_MENU": "👤 {user_name}\n\n📱 Subscription: {subscription_status}\n\nChoose an option:\n", - "MAIN_MENU_ACTION_PROMPT": "Choose an option:", - "MAIN_MENU_BUTTON": "🏠 Main menu", - "MANAGE_DEVICES_BUTTON": "🔧 Manage devices", - "MARK_AS_ANSWERED": "✅ Mark as answered", - "MENU_ADMIN": "⚙️ Admin panel", - "MENU_BALANCE": "💰 Balance", - "MENU_BUY_SUBSCRIPTION": "💎 Buy subscription", - "MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription", - "MENU_LANGUAGE": "🌐 Language", - "MENU_PROMOCODE": "🎫 Promo code", - "MENU_REFERRALS": "🤝 Referral program", - "MENU_RULES": "📋 Service rules", - "MENU_SUBSCRIPTION": "📱 Subscription", - "MENU_SUPPORT": "🛠️ Support", - "MENU_TRIAL": "🎁 Trial subscription", - "MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.", - "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Mulen Pay payment\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 How to pay:\n1. Press ‘Pay with Mulen Pay’\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", - "MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay", - "MULENPAY_TOPUP_PROMPT": "💳 Mulen Pay payment\n\nEnter an amount between 100 and 100,000 ₽.\nThe payment is processed by the secure Mulen Pay platform.", - "MY_BALANCE_BUTTON": "💰 My balance", - "MY_SUBSCRIPTION_BUTTON": "📱 My subscription", - "MY_TICKETS_BUTTON": "📋 My tickets", - "MY_TICKETS_TITLE": "📋 Your tickets:", - "NO": "❌ No", - "NOTIFICATION_CLOSED": "Notification closed.", - "NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.", - "NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.", - "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):", - "NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):", - "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):", - "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):", - "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):", - "NO_ATTACHMENTS": "No attachments.", - "NO_SERVERS_AVAILABLE": "❌ No servers available", - "NO_TICKETS": "You don't have any tickets yet.", - "NO_TICKETS_ADMIN": "No tickets to display.", - "NO_TRAFFIC_PACKAGES": "❌ No packages available", - "OPEN_TICKETS": "🔴 Open", - "OPEN_TICKETS_HEADER": "🔴 Open tickets", - "OPERATION_CANCELLED": "❌ Operation cancelled", - "OTHER_APPS_BUTTON": "📋 Other apps", - "PAGINATION_NEXT": "➡️", - "PAGINATION_PREV": "⬅️", - "PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.", - "PAL24_PAYMENT_INSTRUCTIONS": "💳 PayPalych payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\n1. Press ‘Pay with PayPalych’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", - "PAL24_PAY_BUTTON": "💳 Pay with PayPalych", - "PAL24_TOPUP_PROMPT": "💳 PayPalych payment\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed by the secure PayPalych platform.", - "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable", - "PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)", - "PAYMENT_CARD_PAL24": "💳 Bank card (PayPalych)", - "PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)", - "PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)", - "PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)", - "PAYMENT_METHODS_FOOTER": "Choose a top-up method:", - "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Balance top-up methods\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:", - "PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:", - "PAYMENT_METHODS_TITLE": "💳 Balance top-up methods", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", - "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", - "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", - "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay", - "PAYMENT_METHOD_MULENPAY_NAME": "💳 Bank card (Mulen Pay)", - "PAYMENT_METHOD_PAL24_DESCRIPTION": "via PayPalych", - "PAYMENT_METHOD_PAL24_NAME": "💳 Bank card (PayPalych)", - "PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient", - "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", - "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", - "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", - "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", - "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Bank card", - "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa", - "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Bank card", - "PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)", - "PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars", - "PAYMENT_VIA_SUPPORT": "🛠️ Via support", - "PAY_NOW_BUTTON": "💳 Pay", - "PAY_WITH_COINS_BUTTON": "🪙 Pay", - "PENDING_CANCEL_BUTTON": "⌛ Cancel", - "PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}", - "PERIOD_180_DAYS": "📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}", - "PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}", - "PERIOD_360_DAYS": "📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}", - "PERIOD_60_DAYS": "📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}", - "PERIOD_90_DAYS": "📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}", - "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀", - "PROMOCODE_EMPTY_INPUT": "❌ Please enter a valid promo code", - "PROMOCODE_ENTER": "🎫 Enter promo code", - "PROMOCODE_EXPIRED": "❌ Promo code has expired", - "PROMOCODE_INVALID": "❌ Invalid promo code", - "PROMOCODE_SUCCESS": "🎉 Promo code applied!", - "PROMOCODE_USED": "ℹ️ Promo code has already been used", - "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Your promo group discounts", - "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%", - "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%", - "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%", - "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:", - "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", - "REFERRAL_ANALYTICS_BUTTON": "📊 Analytics", - "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Earnings by period:", - "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• Month: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• Quarter: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Today: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• Week: {amount}", - "REFERRAL_ANALYTICS_FOOTER": "📈 Keep growing your referral network!", - "REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics", - "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} rewards)", - "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Top {count} referrals:", - "REFERRAL_CODE_ACCEPTED": "✅ Referral code accepted!", - "REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.", - "REFERRAL_CODE_INVALID": "❌ Invalid referral code", - "REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.", - "REFERRAL_CODE_QUESTION": "\n🤝 Do you have a friend's referral code?\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n", - "REFERRAL_CODE_SKIP": "⏭️ Skip", - "REFERRAL_CODE_TITLE": "🆔 Your code: {code}", - "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Earnings by type:", - "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Bonuses for first top-ups: {count} ({amount})", - "REFERRAL_EARNINGS_PURCHASES": "• Purchase commissions: {count} ({amount})", - "REFERRAL_EARNINGS_TOPUPS": "• Top-up commissions: {count} ({amount})", - "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Purchase commission", - "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Top-up commission", - "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up", - "REFERRAL_INFO": "\n🤝 Referral program\n\n👥 Invited: {referrals_count} friends\n💰 Earned: {earned_amount}\n\n🔗 Your referral link:\n{referral_link}\n\n🎫 Your promo code:\n{referral_code}\n\n💰 Terms:\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n", - "REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!", - "REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:", - "REFERRAL_INVITE_CREATED_TITLE": "📝 Invitation created!", - "REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection", - "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection", - "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Servers worldwide", - "REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!", - "REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:", - "REFERRAL_INVITE_MESSAGE": "\n🎯 Invitation to the VPN service\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n", - "REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!", - "REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}", - "REFERRAL_LINK_TITLE": "🔗 Your referral link:", - "REFERRAL_LIST_BUTTON": "👥 Referral list", - "REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!", - "REFERRAL_LIST_HEADER": "👥 Your referrals (page {current}/{total})", - "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago", - "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago", - "REFERRAL_LIST_ITEM_EARNED": " 💎 Earned from them: {amount}", - "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", - "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago", - "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Top-ups: {count}", - "REFERRAL_LIST_NEXT_PAGE": "Next ➡️", - "REFERRAL_LIST_PREV_PAGE": "⬅️ Back", - "REFERRAL_PROGRAM_TITLE": "👥 Referral program", - "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Latest rewards:", - "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} from {referral_name}", - "REFERRAL_REWARDS_HEADER": "🎁 How rewards work:", - "REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: {percent}%", - "REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: {bonus}", - "REFERRAL_REWARD_NEW_USER": "• New user receives: {bonus} on the first top-up from {minimum}", - "REFERRAL_SHARE_BUTTON": "📤 Share", - "REFERRAL_STATS_ACTIVE": "• Active referrals: {count}", - "REFERRAL_STATS_CONVERSION": "• Conversion: {rate}%", - "REFERRAL_STATS_FIRST_TOPUPS": "• Made first top-up: {count}", - "REFERRAL_STATS_HEADER": "📊 Your statistics:", - "REFERRAL_STATS_INVITED": "• Invited users: {count}", - "REFERRAL_STATS_MONTH_EARNED": "• Earned last month: {amount}", - "REFERRAL_STATS_TOTAL_EARNED": "• Earned in total: {amount}", - "REGISTRATION_COMPLETING": "✅ Completing registration...", - "REPLY_TO_TICKET": "💬 Reply", - "REPORT_CLOSE": "❌ Close", - "REPORT_CLOSED": "✅ Report closed.", - "REPORT_CLOSE_ERROR": "❌ Failed to close the report.", - "RESET_ALL_DEVICES_BUTTON": "🔄 Reset all devices", - "RESET_DEVICE_CONFIRM_BUTTON": "✅ Reset this device", - "RESET_TRAFFIC_BUTTON": "🔄 Reset traffic", - "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout", - "RULES_ACCEPT": "✅ I accept the rules", - "RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...", - "RULES_DECLINE": "❌ I do not accept", - "RULES_HEADER": "📋 Service Rules", - "RULES_REQUIRED": "❗️ You must accept the rules to use the service!", - "RULES_TEXT_DEFAULT": "📋 Service Usage Rules\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\nBy using the service you agree to follow these rules.", - "SELECT_COUNTRIES": "Select countries:", - "SELECT_DEVICES": "Number of devices:", - "SELECT_PERIOD": "Choose period:", - "SELECT_TRAFFIC": "Choose traffic package:", - "SENDING_ATTACHMENTS": "📎 Sending attachments...", - "SEND_CONTACT_BUTTON": "📱 Share contact", - "SEND_LOCATION_BUTTON": "📍 Share location", - "SHOW_QR_BUTTON": "📱 Show QR code", - "SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link", - "SKIP_BUTTON": "Skip ➡️", - "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.", - "STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.", - "STARS_PAYMENT_SUCCESS": "🎉 Payment processed successfully!\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀", - "STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.", - "STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.", - "STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.", - "STARS_PRECHECK_USER_NOT_FOUND": "User not found. Please contact support.", - "SUBSCRIPTION_ACTIVE": "✅ Active", - "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", - "SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:", - "SUBSCRIPTION_APPS_TITLE": "📱 Apps for {device_name}", - "SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found", - "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", - "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Connected devices:\n", - "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", - "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", - "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", - "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app", - "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Connection link:\n{subscription_url}", - "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", - "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", - "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", - "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server", - "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 How to connect:", - "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", - "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Step 2 - Add subscription:", - "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Step 3 - Connect:", - "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", - "SUBSCRIPTION_EXPIRED": "\n❌ Subscription expired\n\nYour subscription has ended. Renew it to restore access.\n", - "SUBSCRIPTION_EXPIRED_1D": "⛔ Your subscription expired\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}", - "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.", - "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.", - "SUBSCRIPTION_EXPIRING": "\n⚠️ Subscription expiring!\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n", - "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Subscription expires in {days_text}!\n\nYour paid subscription ends on {end_date}.\n\n💳 Autopay: {autopay_status}\n\n{action_text}\n", - "SUBSCRIPTION_EXTEND": "💎 Extend subscription", - "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.", - "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.", - "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually: {subscription_link}", - "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Open link in Happ", - "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Connect via Happ", - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device", - "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Your import link for the VPN app:\n{subscription_url}", - "SUBSCRIPTION_INFO": "\n📱 Subscription details\n\n📊 Status: {status}\n🎭 Type: {type}\n📅 Valid until: {end_date}\n⏰ Days left: {days_left}\n\n📈 Traffic: {traffic_used} / {traffic_limit}\n🌍 Servers: {countries_count} countries\n📱 Devices: {devices_used} / {devices_limit}\n\n💳 Autopay: {autopay_status}\n", - "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.", - "SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.", - "SUBSCRIPTION_LINK_STEP1": "1. Tap the link above to copy it", - "SUBSCRIPTION_LINK_STEP2": "2. Open your VPN app", - "SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option", - "SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link", - "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", - "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 How to use:", - "SUBSCRIPTION_NONE": "❌ No active subscription", - "SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found", - "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", - "SUBSCRIPTION_NO_SERVERS": "No servers", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", - "SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!", - "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings", - "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", - "SUBSCRIPTION_STATUS_ACTIVE": "Active", - "SUBSCRIPTION_STATUS_EXPIRED": "Expired", - "SUBSCRIPTION_STATUS_TRIAL": "Trial", - "SUBSCRIPTION_STATUS_UNKNOWN": "Unknown", - "SUBSCRIPTION_SUMMARY": "\n📋 Final configuration\n\n📅 Period: {period} days\n📈 Traffic: {traffic}\n🌍 Countries: {countries}\n📱 Devices: {devices}\n\n💰 Total: {total_price}\n\nConfirm the purchase?\n", - "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} days", - "SUBSCRIPTION_TIME_LEFT_EXPIRED": "expired", - "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} hr", - "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} min", - "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} GB", - "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (unlimited) | Used: {used} GB", - "SUBSCRIPTION_TRIAL": "🧪 Trial subscription", - "SUBSCRIPTION_TYPE_PAID": "Paid", - "SUBSCRIPTION_TYPE_TRIAL": "Trial", - "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 expires in a few minutes!", - "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ expires today!", - "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ expires tomorrow!", - "SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days", - "SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)", - "SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!", - "SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!", - "SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}", - "SUB_STATUS_NONE": "❌ Not available", - "SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)", - "SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!", - "SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!", - "SUCCESS": "✅ Success", - "SUPPORT_BUTTON": "🆘 Support", - "SUPPORT_INFO": "\n🛠️ Technical support\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n", - "SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic", - "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Confirm traffic change\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", - "SWITCH_TRAFFIC_INFO": "\n🔄 Switch traffic limit\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 Important:\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n", - "SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Traffic limit decreased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\nℹ️ Payments are not refunded\n", - "SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Traffic limit increased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\n💰 Charged: {amount}\n", - "SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit", - "TICKET_ATTACHMENTS": "📎 Attachments", - "TICKET_CLOSED": "✅ Ticket closed.", - "TICKET_CLOSE_ERROR": "❌ Error closing ticket.", - "TICKET_CREATED_SUCCESS": "✅ Ticket #{ticket_id} created successfully!\n\nTitle: {title}\n\nWe will respond to you soon.", - "TICKET_CREATION_CANCELLED": "Ticket creation cancelled.", - "TICKET_CREATION_ERROR": "❌ An error occurred while creating the ticket. Please try again later.", - "TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.", - "TICKET_MESSAGE_INPUT": "Now describe your problem or question:", - "TICKET_MESSAGE_TOO_SHORT": "Message must contain at least 10 characters. Try again:", - "TICKET_NOT_FOUND": "Ticket not found.", - "TICKET_PRIORITY_HIGH": "🟠 High", - "TICKET_PRIORITY_LOW": "🟢 Low", - "TICKET_PRIORITY_NORMAL": "🟡 Normal", - "TICKET_PRIORITY_SELECT": "Select ticket priority:", - "TICKET_PRIORITY_URGENT": "🔴 Urgent", - "TICKET_REPLY_CANCELLED": "Reply cancelled.", - "TICKET_REPLY_ERROR": "❌ An error occurred while sending the reply. Please try again later.", - "TICKET_REPLY_INPUT": "Enter your reply:", - "TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:", - "TICKET_REPLY_SENT": "✅ Your reply has been sent!", - "TICKET_REPLY_TOO_SHORT": "Reply must contain at least 5 characters. Try again:", - "TICKET_STATUS_ANSWERED": "Answered", - "TICKET_STATUS_CLOSED": "Closed", - "TICKET_STATUS_OPEN": "Open", - "TICKET_STATUS_PENDING": "Pending", - "TICKET_TITLE_INPUT": "Enter ticket title:", - "TICKET_TITLE_TOO_LONG": "Title is too long. Maximum 255 characters. Try again:", - "TICKET_TITLE_TOO_SHORT": "Title must contain at least 5 characters. Try again:", - "TICKET_UPDATE_ERROR": "❌ Error updating ticket.", - "TOPUP_BALANCE_BUTTON": "💳 Top up balance", - "TOP_UP_AMOUNT": "💳 Enter top-up amount (in rubles):", - "TOP_UP_METHODS": "\n💳 Select a payment method\n\nAmount: {amount}\n", - "TOP_UP_STARS": "⭐ Telegram Stars", - "TOP_UP_TRIBUTE": "💎 Bank card", - "TRAFFIC_100GB": "📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", - "TRAFFIC_10GB": "📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", - "TRAFFIC_250GB": "📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", - "TRAFFIC_25GB": "📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}", - "TRAFFIC_50GB": "📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}", - "TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", - "TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", - "TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed", - "TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured", - "TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", - "TRIAL_ACTIVATED": "🎉 Trial subscription activated!", - "TRIAL_ACTIVATE_BUTTON": "🎁 Activate", - "TRIAL_ALREADY_USED": "❌ The trial subscription has already been used", - "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic} GB\n📱 Devices: {devices} pcs\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", - "TRIAL_ENDING_SOON": "\n🎁 The trial subscription is ending soon!\n\nYour trial expires in a few hours.\n\n💎 Don't want to lose VPN access?\nSwitch to the full subscription!\n\n🔥 Special offer:\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n", - "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!", - "TRIAL_INACTIVE_24H": "⏳ A full day passed without activity\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!", - "UNBLOCK": "✅ Unblock", - "UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.", - "UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.", - "USER_NOT_FOUND": "❌ User not found", - "VIEW_TICKET": "👁️ View ticket", - "WELCOME": "\n🎉 Welcome to VPN Service!\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 Advantages:\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n", - "WELCOME_FALLBACK": "Welcome, {user_name}!", - "YES": "✅ Yes" + "ADD_COUNTRIES_BUTTON": "🌐 Add countries", + "ADMIN_MAIN_MENU": "🏠 Main menu", + "ADMIN_CAMPAIGNS": "📣 Promotional campaigns", + "ADMIN_REPORTS": "📊 Reports", + "AUTOPAY_BUTTON": "💳 Auto payment", + "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days", + "BACK": "⬅️ Back", + "BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription", + "BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}", + "CANCEL": "❌ Cancel", + "CHANGE_DEVICES_BUTTON": "📱 Change devices", + "CHANNEL_CHECK_BUTTON": "✅ I have joined", + "CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.", + "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe", + "CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!", + "CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing", + "CHECK_STATUS_BUTTON": "📊 Check status", + "CHOOSE_ANOTHER_DEVICE": "📱 Choose another device", + "CONFIRM": "✅ Confirm", + "CONFIRM_CHANGE_BUTTON": "✅ Confirm change", + "CONNECT_BUTTON": "🔗 Connect", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ", + "HAPP_DOWNLOAD_PROMPT": "📥 Download Happ\nChoose your device:", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "HAPP_PLATFORM_PC": "💻 PC", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link", + "CONTINUE": "➡️ Continue", + "CONTINUE_BUTTON": "➡️ Continue", + "COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link", + "CREATE_INVITE_BUTTON": "📝 Create invite", + "DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?", + "DEVICE_GUIDE_ANDROID": "🤖 Android", + "DEVICE_GUIDE_ANDROID_TV": "📺 Android TV", + "DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)", + "DEVICE_GUIDE_MAC": "🎯 macOS", + "DEVICE_GUIDE_WINDOWS": "💻 Windows", + "DISABLE_BUTTON": "❌ Disable", + "ENABLE_BUTTON": "✅ Enable", + "ERROR": "❌ An error occurred", + "ERROR_TRY_AGAIN": "❌ An error occurred. Please try again.", + "ERROR_RULES_RETRY": "An error occurred. Please try accepting the rules again:", + "GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up", + "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout", + "INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Insufficient funds\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.", + "LANGUAGE_SELECTED": "🌐 Interface language set: English", + "LOADING": "⏳ Loading...", + "MAIN_MENU": "👤 {user_name}\n\n📱 Subscription: {subscription_status}\n\nChoose an option:\n", + "MAIN_MENU_ACTION_PROMPT": "Choose an option:", + "MAIN_MENU_BUTTON": "🏠 Main menu", + "MANAGE_DEVICES_BUTTON": "🔧 Manage devices", + "MENU_BALANCE": "💰 Balance", + "MENU_SUBSCRIPTION": "📱 Subscription", + "MENU_TRIAL": "🎁 Trial subscription", + "MY_BALANCE_BUTTON": "💰 My balance", + "MY_SUBSCRIPTION_BUTTON": "📱 My subscription", + "NO": "❌ No", + "NO_SERVERS_AVAILABLE": "❌ No servers available", + "NO_TRAFFIC_PACKAGES": "❌ No packages available", + "OTHER_APPS_BUTTON": "📋 Other apps", + "PAGINATION_NEXT": "➡️", + "PAGINATION_PREV": "⬅️", + "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable", + "PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)", + "PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)", + "PAYMENT_CARD_PAL24": "💳 Bank card (PayPalych)", + "PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)", + "PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)", + "PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)", + "PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars", + "PAYMENT_VIA_SUPPORT": "🛠️ Via support", + "PAY_NOW_BUTTON": "💳 Pay", + "PAY_WITH_COINS_BUTTON": "🪙 Pay", + "MULENPAY_TOPUP_PROMPT": "💳 Mulen Pay payment\n\nEnter an amount between 100 and 100,000 ₽.\nThe payment is processed by the secure Mulen Pay platform.", + "MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.", + "MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay", + "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Mulen Pay payment\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 How to pay:\n1. Press ‘Pay with Mulen Pay’\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", + "PAL24_TOPUP_PROMPT": "💳 PayPalych payment\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed by the secure PayPalych platform.", + "PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.", + "PAL24_PAY_BUTTON": "💳 Pay with PayPalych", + "PAL24_PAYMENT_INSTRUCTIONS": "💳 PayPalych payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\n1. Press ‘Pay with PayPalych’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", + "PENDING_CANCEL_BUTTON": "⌛ Cancel", + "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀", + "REFERRAL_ANALYTICS_BUTTON": "📊 Analytics", + "REFERRAL_CODE_ACCEPTED": "✅ Referral code accepted!", + "REFERRAL_CODE_INVALID": "❌ Invalid referral code", + "REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.", + "REFERRAL_CODE_QUESTION": "\n🤝 Do you have a friend's referral code?\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n", + "REFERRAL_CODE_SKIP": "⏭️ Skip", + "ALREADY_REGISTERED_REFERRAL": "ℹ️ You are already registered. A referral link cannot be applied.", + "REFERRAL_LIST_BUTTON": "👥 Referral list", + "RESET_ALL_DEVICES_BUTTON": "🔄 Reset all devices", + "RESET_DEVICE_CONFIRM_BUTTON": "✅ Reset this device", + "RESET_TRAFFIC_BUTTON": "🔄 Reset traffic", + "RULES_HEADER": "📋 Service Rules", + "RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...", + "RULES_TEXT_DEFAULT": "📋 Service Usage Rules\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\nBy using the service you agree to follow these rules.", + "SEND_CONTACT_BUTTON": "📱 Share contact", + "SEND_LOCATION_BUTTON": "📍 Share location", + "SHOW_QR_BUTTON": "📱 Show QR code", + "SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link", + "SKIP_BUTTON": "Skip ➡️", + "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings", + "SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days", + "SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)", + "SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!", + "SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!", + "SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}", + "SUB_STATUS_NONE": "❌ Not available", + "SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)", + "SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!", + "SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!", + "SUBSCRIPTION_ACTIVE": "✅ Active", + "SUBSCRIPTION_EXTEND": "💎 Extend subscription", + "SUCCESS": "✅ Success", + "REGISTRATION_COMPLETING": "✅ Completing registration...", + "SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic", + "TOPUP_BALANCE_BUTTON": "💳 Top up balance", + "TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured", + "TRIAL_ACTIVATE_BUTTON": "🎁 Activate", + "PROMOCODE_EMPTY_INPUT": "❌ Please enter a valid promo code", + "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.", + "STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.", + "STARS_PAYMENT_SUCCESS": "🎉 Payment processed successfully!\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀", + "STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.", + "STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.", + "STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.", + "STARS_PRECHECK_USER_NOT_FOUND": "User not found. Please contact support.", + "UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.", + "UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.", + "WELCOME": "\n🎉 Welcome to VPN Service!\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 Advantages:\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n", + "WELCOME_FALLBACK": "Welcome, {user_name}!", + "YES": "✅ Yes", + "ACCESS_DENIED": "❌ Access denied", + "ADMIN_MESSAGES": "📨 Broadcasts", + "ADMIN_MONITORING": "🔍 Monitoring", + "ADMIN_MONITORING_SETTINGS": "⚙️ Monitoring settings", + "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", + "ADMIN_PROMOCODES": "🎫 Promo codes", + "ADMIN_REFERRALS": "🤝 Referral program", + "ADMIN_REMNAWAVE": "🖥️ Remnawave", + "ADMIN_RULES": "📋 Rules", + "ADMIN_STATISTICS": "📊 Statistics", + "ADMIN_PROMO_GROUPS": "💳 Promo groups", + "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", + "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", + "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", + "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", + "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", + "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", + "CREATE_TICKET_BUTTON": "🎫 Create ticket", + "MY_TICKETS_BUTTON": "📋 My tickets", + "CONTACT_SUPPORT_BUTTON": "💬 Contact support", + "SUPPORT_BUTTON": "🆘 Support", + "TICKET_PRIORITY_SELECT": "Select ticket priority:", + "TICKET_PRIORITY_LOW": "🟢 Low", + "TICKET_PRIORITY_NORMAL": "🟡 Normal", + "TICKET_PRIORITY_HIGH": "🟠 High", + "TICKET_PRIORITY_URGENT": "🔴 Urgent", + "CANCEL_TICKET_CREATION": "❌ Cancel ticket creation", + "TICKET_TITLE_INPUT": "Enter ticket title:", + "TICKET_TITLE_TOO_SHORT": "Title must contain at least 5 characters. Try again:", + "TICKET_TITLE_TOO_LONG": "Title is too long. Maximum 255 characters. Try again:", + "TICKET_MESSAGE_INPUT": "Now describe your problem or question:", + "TICKET_MESSAGE_TOO_SHORT": "Message must contain at least 10 characters. Try again:", + "TICKET_CREATED_SUCCESS": "✅ Ticket #{ticket_id} created successfully!\n\nTitle: {title}\n\nWe will respond to you soon.", + "VIEW_TICKET": "👁️ View ticket", + "BACK_TO_MENU": "🏠 Back to menu", + "TICKET_CREATION_ERROR": "❌ An error occurred while creating the ticket. Please try again later.", + "NO_TICKETS": "You don't have any tickets yet.", + "MY_TICKETS_TITLE": "📋 Your tickets:", + "TICKET_STATUS_OPEN": "Open", + "TICKET_STATUS_ANSWERED": "Answered", + "TICKET_STATUS_CLOSED": "Closed", + "TICKET_STATUS_PENDING": "Pending", + "REPLY_TO_TICKET": "💬 Reply", + "CLOSE_TICKET": "🔒 Close ticket", + "CANCEL_REPLY": "❌ Cancel reply", + "TICKET_REPLY_INPUT": "Enter your reply:", + "TICKET_REPLY_TOO_SHORT": "Reply must contain at least 5 characters. Try again:", + "TICKET_REPLY_SENT": "✅ Your reply has been sent!", + "TICKET_REPLY_ERROR": "❌ An error occurred while sending the reply. Please try again later.", + "TICKET_CLOSED": "✅ Ticket closed.", + "TICKET_CLOSE_ERROR": "❌ Error closing ticket.", + "TICKET_NOT_FOUND": "Ticket not found.", + "TICKET_CREATION_CANCELLED": "Ticket creation cancelled.", + "BACK_TO_SUPPORT": "⬅️ Back to support", + "TICKET_REPLY_CANCELLED": "Reply cancelled.", + "BACK_TO_TICKETS": "⬅️ Back to tickets", + "NO_TICKETS_ADMIN": "No tickets to display.", + "ADMIN_TICKETS_TITLE": "🎫 All support tickets:", + "ADMIN_TICKET_REPLY_INPUT": "Enter support reply:", + + "ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!", + "TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.", + "TICKET_UPDATE_ERROR": "❌ Error updating ticket.", + "MARK_AS_ANSWERED": "✅ Mark as answered", + "TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:", + "CLOSE_NOTIFICATION": "❌ Close notification", + "REPORT_CLOSE": "❌ Close", + "REPORT_CLOSED": "✅ Report closed.", + "REPORT_CLOSE_ERROR": "❌ Failed to close the report.", + "NOTIFICATION_CLOSED": "Notification closed.", + "UNBLOCK": "✅ Unblock", + "BLOCK_FOREVER": "🚫 Block permanently", + "BLOCK_BY_TIME": "⏳ Temporary block", + "ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):", + "TICKET_ATTACHMENTS": "📎 Attachments", + "OPEN_TICKETS": "🔴 Open", + "CLOSED_TICKETS": "🟢 Closed", + "OPEN_TICKETS_HEADER": "🔴 Open tickets", + "CLOSED_TICKETS_HEADER": "🟢 Closed tickets", + "SENDING_ATTACHMENTS": "📎 Sending attachments...", + "NO_ATTACHMENTS": "No attachments.", + "ATTACHMENTS_SENT": "✅ Attachments sent.", + "DELETE_MESSAGE": "🗑 Delete", + "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group", + "ADMIN_USER_PROMO_GROUP_TITLE": "👥 User promo group", + "ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}", + "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.", + "ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:", + "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”", + "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.", + "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.", + "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user", + "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}", + "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", + "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", + "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", + "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", + "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete", + "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:", + "ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.", + "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", + "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", + "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", + "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", + "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", + "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100). Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100). Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100). Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", + "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽", + "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”", + "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:", + "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename", + "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", + "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.", + "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", + "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", + "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", + "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.", + "ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.", + "ADMIN_SUBSCRIPTIONS": "📱 Subscriptions", + "ADMIN_USERS": "👥 Users", + "AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!", + "AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically", + "AUTOPAY_FAILED": "\n❌ Autopay failed\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n", + "AUTOPAY_SUCCESS": "\n✅ Autopay completed\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n", + "BALANCE_BUTTON": "💰 Balance: {balance}", + "BALANCE_BUTTON_ZERO": "💰 Balance: 0 ₽", + "BALANCE_HISTORY": "📊 Transaction history", + "BALANCE_INFO": "\n💰 Balance: {balance}\n\nChoose an action:\n", + "BALANCE_SUPPORT_REQUEST": "🛠️ Request via support", + "BALANCE_TOP_UP": "💳 Top up", + "BALANCE_TOPUP": "💳 Top up balance", + "CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.", + "CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!", + "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!", + "BUY_SUBSCRIPTION_START": "\n💎 Subscription setup\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n", + "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Your promo group discounts", + "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%", + "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%", + "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%", + "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:", + "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", + "CHANGE_DEVICES_CONFIRM": "\n📱 Confirm change\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", + "CHANGE_DEVICES_INFO": "\n📱 Adjust device limit\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 Important:\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n", + "CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n", + "CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n", + "CHANGE_DEVICES_TITLE": "📱 Change device limit", + "CONTACT_SUPPORT": "💬 Contact support", + "CREATE_INVITE": "📝 Create invite", + "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", + "DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})", + "DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}", + "DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed", + "INVALID_AMOUNT": "❌ Invalid amount", + "MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n", + "MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n", + "MENU_ADMIN": "⚙️ Admin panel", + "MENU_BUY_SUBSCRIPTION": "💎 Buy subscription", + "MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription", + "MENU_PROMOCODE": "🎫 Promo code", + "MENU_REFERRALS": "🤝 Referral program", + "MENU_RULES": "📋 Service rules", + "MENU_SUPPORT": "🛠️ Support", + "OPERATION_CANCELLED": "❌ Operation cancelled", + "PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}", + "PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}", + "PERIOD_60_DAYS": "📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}", + "PERIOD_90_DAYS": "📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}", + "PERIOD_180_DAYS": "📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}", + "PERIOD_360_DAYS": "📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}", + "PROMOCODE_ENTER": "🎫 Enter promo code", + "PROMOCODE_EXPIRED": "❌ Promo code has expired", + "PROMOCODE_INVALID": "❌ Invalid promo code", + "PROMOCODE_SUCCESS": "🎉 Promo code applied!", + "PROMOCODE_USED": "ℹ️ Promo code has already been used", + "REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.", + "REFERRAL_INFO": "\n🤝 Referral program\n\n👥 Invited: {referrals_count} friends\n💰 Earned: {earned_amount}\n\n🔗 Your referral link:\n{referral_link}\n\n🎫 Your promo code:\n{referral_code}\n\n💰 Terms:\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n", + "REFERRAL_INVITE_MESSAGE": "\n🎯 Invitation to the VPN service\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n", + "RULES_ACCEPT": "✅ I accept the rules", + "RULES_DECLINE": "❌ I do not accept", + "RULES_REQUIRED": "❗️ You must accept the rules to use the service!", + "SELECT_COUNTRIES": "Select countries:", + "SELECT_DEVICES": "Number of devices:", + "SELECT_PERIOD": "Choose period:", + "SELECT_TRAFFIC": "Choose traffic package:", + "SUBSCRIPTION_EXPIRED": "\n❌ Subscription expired\n\nYour subscription has ended. Renew it to restore access.\n", + "SUBSCRIPTION_EXPIRING": "\n⚠️ Subscription expiring!\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n", + "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Subscription expires in {days_text}!\n\nYour paid subscription ends on {end_date}.\n\n💳 Autopay: {autopay_status}\n\n{action_text}\n", + "SUBSCRIPTION_INFO": "\n📱 Subscription details\n\n📊 Status: {status}\n🎭 Type: {type}\n📅 Valid until: {end_date}\n⏰ Days left: {days_left}\n\n📈 Traffic: {traffic_used} / {traffic_limit}\n🌍 Servers: {countries_count} countries\n📱 Devices: {devices_used} / {devices_limit}\n\n💳 Autopay: {autopay_status}\n", + "SUBSCRIPTION_NONE": "❌ No active subscription", + "SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found", + "SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!", + "SUBSCRIPTION_SUMMARY": "\n📋 Final configuration\n\n📅 Period: {period} days\n📈 Traffic: {traffic}\n🌍 Countries: {countries}\n📱 Devices: {devices}\n\n💰 Total: {total_price}\n\nConfirm the purchase?\n", + "SUBSCRIPTION_TRIAL": "🧪 Trial subscription", + "SUPPORT_INFO": "\n🛠️ Technical support\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n", + "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Confirm traffic change\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", + "SWITCH_TRAFFIC_INFO": "\n🔄 Switch traffic limit\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 Important:\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n", + "SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Traffic limit decreased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\nℹ️ Payments are not refunded\n", + "SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Traffic limit increased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\n💰 Charged: {amount}\n", + "SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit", + "TOP_UP_AMOUNT": "💳 Enter top-up amount (in rubles):", + "TOP_UP_METHODS": "\n💳 Select a payment method\n\nAmount: {amount}\n", + "TOP_UP_STARS": "⭐ Telegram Stars", + "TOP_UP_TRIBUTE": "💎 Bank card", + "TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", + "TRAFFIC_10GB": "📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", + "TRAFFIC_25GB": "📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}", + "TRAFFIC_50GB": "📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}", + "TRAFFIC_100GB": "📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", + "TRAFFIC_250GB": "📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", + "TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", + "TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}", + "TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed", + "TRIAL_ACTIVATED": "🎉 Trial subscription activated!", + "TRIAL_ALREADY_USED": "❌ The trial subscription has already been used", + "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic} GB\n📱 Devices: {devices} pcs\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", + "TRIAL_ENDING_SOON": "\n🎁 The trial subscription is ending soon!\n\nYour trial expires in a few hours.\n\n💎 Don't want to lose VPN access?\nSwitch to the full subscription!\n\n🔥 Special offer:\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n", + "USER_NOT_FOUND": "❌ User not found", + "MENU_LANGUAGE": "🌐 Language", + "SUBSCRIPTION_STATUS_EXPIRED": "Expired", + "SUBSCRIPTION_STATUS_TRIAL": "Trial", + "SUBSCRIPTION_STATUS_ACTIVE": "Active", + "SUBSCRIPTION_STATUS_UNKNOWN": "Unknown", + "SUBSCRIPTION_TIME_LEFT_EXPIRED": "expired", + "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} days", + "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} hr", + "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} min", + "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ expires tomorrow!", + "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ expires today!", + "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 expires in a few minutes!", + "SUBSCRIPTION_TYPE_TRIAL": "Trial", + "SUBSCRIPTION_TYPE_PAID": "Paid", + "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (unlimited) | Used: {used} GB", + "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} GB", + "SUBSCRIPTION_NO_SERVERS": "No servers", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Connected devices:\n", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", + "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Connection link:\n{subscription_url}", + "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app", + "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Your import link for the VPN app:\n{subscription_url}", + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device", + "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.", + "BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu", + "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured", + "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.", + "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", + "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Connect subscription\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:", + "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", + "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Connect subscription\n\n🔗 Tap the button below to open the subscription link:", + "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", + "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable", + "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device", + "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Setup for {device_name}", + "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Connect via Happ", + "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Open link in Happ", + "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually: {subscription_link}", + "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.", + "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Subscription link:", + "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Recommended app: {app_name}", + "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Step 1 - Install:", + "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Step 2 - Add subscription:", + "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Step 3 - Connect:", + "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 How to connect:", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server", + "SUBSCRIPTION_APPS_TITLE": "📱 Apps for {device_name}", + "SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:", + "SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found", + "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", + "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", + "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 How to use:", + "SUBSCRIPTION_LINK_STEP1": "1. Tap the link above to copy it", + "SUBSCRIPTION_LINK_STEP2": "2. Open your VPN app", + "SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option", + "SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link", + "SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.", + "REFERRAL_PROGRAM_TITLE": "👥 Referral program", + "REFERRAL_STATS_HEADER": "📊 Your statistics:", + "REFERRAL_STATS_INVITED": "• Invited users: {count}", + "REFERRAL_STATS_FIRST_TOPUPS": "• Made first top-up: {count}", + "REFERRAL_STATS_ACTIVE": "• Active referrals: {count}", + "REFERRAL_STATS_CONVERSION": "• Conversion: {rate}%", + "REFERRAL_STATS_TOTAL_EARNED": "• Earned in total: {amount}", + "REFERRAL_STATS_MONTH_EARNED": "• Earned last month: {amount}", + "REFERRAL_REWARDS_HEADER": "🎁 How rewards work:", + "REFERRAL_REWARD_NEW_USER": "• New user receives: {bonus} on the first top-up from {minimum}", + "REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: {bonus}", + "REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: {percent}%", + "REFERRAL_LINK_TITLE": "🔗 Your referral link:", + "REFERRAL_CODE_TITLE": "🆔 Your code: {code}", + "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Latest rewards:", + "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up", + "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Top-up commission", + "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Purchase commission", + "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} from {referral_name}", + "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Earnings by type:", + "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Bonuses for first top-ups: {count} ({amount})", + "REFERRAL_EARNINGS_TOPUPS": "• Top-up commissions: {count} ({amount})", + "REFERRAL_EARNINGS_PURCHASES": "• Purchase commissions: {count} ({amount})", + "REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!", + "REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}", + "REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!", + "REFERRAL_LIST_HEADER": "👥 Your referrals (page {current}/{total})", + "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", + "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Top-ups: {count}", + "REFERRAL_LIST_ITEM_EARNED": " 💎 Earned from them: {amount}", + "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago", + "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago", + "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago", + "REFERRAL_LIST_PREV_PAGE": "⬅️ Back", + "REFERRAL_LIST_NEXT_PAGE": "Next ➡️", + "REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics", + "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Earnings by period:", + "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Today: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• Week: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• Month: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• Quarter: {amount}", + "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Top {count} referrals:", + "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} rewards)", + "REFERRAL_ANALYTICS_FOOTER": "📈 Keep growing your referral network!", + "REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!", + "REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!", + "REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection", + "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Servers worldwide", + "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection", + "REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:", + "REFERRAL_SHARE_BUTTON": "📤 Share", + "REFERRAL_INVITE_CREATED_TITLE": "📝 Invitation created!", + "REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:", + "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Balance top-up methods\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:", + "PAYMENT_METHODS_TITLE": "💳 Balance top-up methods", + "PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:", + "PAYMENT_METHODS_FOOTER": "Choose a top-up method:", + "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", + "PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient", + "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Bank card", + "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa", + "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Bank card", + "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute", + "PAYMENT_METHOD_MULENPAY_NAME": "💳 Bank card (Mulen Pay)", + "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay", + "PAYMENT_METHOD_PAL24_NAME": "💳 Bank card (PayPalych)", + "PAYMENT_METHOD_PAL24_DESCRIPTION": "via PayPalych", + "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", + "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", + "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", + "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", + "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!", + "TRIAL_INACTIVE_24H": "⏳ A full day passed without activity\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Your subscription expired\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.", + "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.", + "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", + "NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.", + "NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.", + "NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):", + "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):", + "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):", + "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):" } diff --git a/locales/ru.json b/locales/ru.json index 8532957c..2524c1d4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1,534 +1,525 @@ { - "ACCESS_DENIED": "❌ Доступ запрещен", - "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Недостаточно средств\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.", - "ADD_COUNTRIES_BUTTON": "🌐 Добавить страны", - "ADMIN_CAMPAIGNS": "📣 Рекламные кампании", - "ADMIN_MAIN_MENU": "🏠 Главное меню", - "ADMIN_MESSAGES": "📨 Рассылки", - "ADMIN_MONITORING": "🔍 Мониторинг", - "ADMIN_MONITORING_SETTINGS": "⚙️ Настройки мониторинга", - "ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n", - "ADMIN_PROMOCODES": "🎫 Промокоды", - "ADMIN_PROMO_GROUPS": "💳 Промогруппы", - "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", - "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", - "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", - "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", - "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Скидки на докупку доп. услуг: отключены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Скидки на докупку доп. услуг: включены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED_VALUE": "отключены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED_VALUE": "включены", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽", - "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", - "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", - "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT": "Включать скидки на докупку доп. услуг при действующих скидках? (да/нет)", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", - "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:", - "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", - "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", - "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", - "ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.", - "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить", - "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.", - "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", - "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", - "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", - "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}", - "ADMIN_PROMO_GROUP_EDIT_ADDON_DISCOUNT_PROMPT": "Включать скидки на докупку доп. услуг? Текущее значение: {current}.", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", - "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100). Текущее значение: {current}.", - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS": "🛒 Скидки на доп. услуги", - "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", - "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", - "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название", - "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", - "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", - "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик", - "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:", - "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»", - "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", - "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100). Текущее значение: {current}.", - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100). Текущее значение: {current}.", - "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Введите «да» или «нет».", - "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.", - "ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.", - "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", - "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", - "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", - "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", - "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", - "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", - "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", - "ADMIN_REFERRALS": "🤝 Партнерка", - "ADMIN_REMNAWAVE": "🖥️ Remnawave", - "ADMIN_REPORTS": "📊 Отчеты", - "ADMIN_RULES": "📋 Правила", - "ADMIN_STATISTICS": "📊 Статистика", - "ADMIN_SUBSCRIPTIONS": "📱 Подписки", - "ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:", - "ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:", - "ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!", - "ADMIN_USERS": "👥 Пользователи", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_LINE": "Скидки на доп. услуги при докупке: {status}", - "ADMIN_USER_PROMO_GROUP_ADDON_DISCOUNT_NONE": "Скидки на доп. услуги при докупке: —", - "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.", - "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", - "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа", - "ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}", - "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена", - "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%, докупка: {addons}", - "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.", - "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.", - "ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:", - "ADMIN_USER_PROMO_GROUP_TITLE": "👥 Промогруппа пользователя", - "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»", - "ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", - "ATTACHMENTS_SENT": "✅ Вложения отправлены.", - "AUTOPAY_BUTTON": "💳 Автоплатёж", - "AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!", - "AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически", - "AUTOPAY_FAILED": "\n❌ Ошибка автоплатежа\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n", - "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни", - "AUTOPAY_SUCCESS": "\n✅ Автоплатеж выполнен\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n", - "BACK": "⬅️ Назад", - "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню", - "BACK_TO_MENU": "🏠 В главное меню", - "BACK_TO_SUBSCRIPTION": "⬅️ К подписке", - "BACK_TO_SUPPORT": "⬅️ К поддержке", - "BACK_TO_TICKETS": "⬅️ К тикетам", - "BALANCE_BUTTON": "💰 Баланс: {balance}", - "BALANCE_BUTTON_DEFAULT": "💰 Баланс: {balance}", - "BALANCE_BUTTON_ZERO": "💰 Баланс: 0 ₽", - "BALANCE_HISTORY": "📊 История операций", - "BALANCE_INFO": "\n💰 Баланс: {balance}\n\nВыберите действие:\n", - "BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку", - "BALANCE_TOPUP": "💳 Пополнить баланс", - "BALANCE_TOP_UP": "💳 Пополнить", - "BLOCK_BY_TIME": "⏳ Блокировка по времени", - "BLOCK_FOREVER": "🚫 Заблокировать", - "BUY_SUBSCRIPTION_START": "\n💎 Настройка подписки\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n", - "CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!", - "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!", - "CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.", - "CANCEL": "❌ Отмена", - "CANCEL_REPLY": "❌ Отменить ответ", - "CANCEL_TICKET_CREATION": "❌ Отменить создание тикета", - "CHANGE_DEVICES_BUTTON": "📱 Изменить устройства", - "CHANGE_DEVICES_CONFIRM": "\n 📱 Подтверждение изменения\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ", - "CHANGE_DEVICES_INFO": "\n 📱 Изменение количества устройств\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 Важно:\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ", - "CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ", - "CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ", - "CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств", - "CHANNEL_CHECK_BUTTON": "✅ Я подписался", - "CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.", - "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться", - "CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!", - "CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку", - "CHECK_STATUS_BUTTON": "📊 Проверить статус", - "CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство", - "CLOSED_TICKETS": "🟢 Закрытые", - "CLOSED_TICKETS_HEADER": "🟢 Закрытые тикеты", - "CLOSE_NOTIFICATION": "❌ Закрыть уведомление", - "CLOSE_TICKET": "🔒 Закрыть тикет", - "CONFIRM": "✅ Подтвердить", - "CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение", - "CONNECT_BUTTON": "🔗 Подключиться", - "CONTACT_SUPPORT": "💬 Написать в поддержку", - "CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой", - "CONTINUE": "➡️ Продолжить", - "CONTINUE_BUTTON": "✅ Продолжить", - "COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки", - "CREATE_INVITE": "📝 Создать приглашение", - "CREATE_INVITE_BUTTON": "📝 Создать приглашение", - "CREATE_TICKET_BUTTON": "🎫 Создать тикет", - "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена", - "DELETE_MESSAGE": "🗑 Удалить", - "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}", - "DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})", - "DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}", - "DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось", - "DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?", - "DEVICE_GUIDE_ANDROID": "🤖 Android", - "DEVICE_GUIDE_ANDROID_TV": "📺 Android TV", - "DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)", - "DEVICE_GUIDE_MAC": "🎯 macOS", - "DEVICE_GUIDE_WINDOWS": "💻 Windows", - "DISABLE_BUTTON": "❌ Выключить", - "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", - "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", - "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", - "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", - "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", - "ENABLE_BUTTON": "✅ Включить", - "ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):", - "ERROR": "❌ Произошла ошибка", - "ERROR_RULES_RETRY": "Произошла ошибка. Попробуйте принять правила еще раз:", - "ERROR_TRY_AGAIN": "❌ Произошла ошибка. Попробуйте еще раз.", - "GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса", - "HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ", - "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", - "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", - "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", - "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", - "HAPP_PLATFORM_ANDROID": "🤖 Android", - "HAPP_PLATFORM_IOS": "🍎 iOS", - "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", - "HAPP_PLATFORM_PC": "💻 ПК", - "HAPP_PLATFORM_WINDOWS": "💻 Windows", - "INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n Пополните баланс на {amount} и попробуйте снова.\n ", - "INVALID_AMOUNT": "❌ Неверная сумма", - "LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: Русский", - "LOADING": "⏳ Загрузка...", - "MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n", - "MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n", - "MAIN_MENU": "👤 {user_name}\n \n📱 Подписка: {subscription_status}\n\nВыберите действие:\n", - "MAIN_MENU_ACTION_PROMPT": "Выберите действие:", - "MAIN_MENU_BUTTON": "🏠 Главное меню", - "MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами", - "MARK_AS_ANSWERED": "✅ Отметить как отвеченный", - "MENU_ADMIN": "⚙️ Админ-панель", - "MENU_BALANCE": "💰 Баланс", - "MENU_BUY_SUBSCRIPTION": "💎 Купить подписку", - "MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку", - "MENU_LANGUAGE": "🌐 Язык", - "MENU_PROMOCODE": "🎫 Промокод", - "MENU_REFERRALS": "🤝 Партнерка", - "MENU_RULES": "📋 Правила сервиса", - "MENU_SUBSCRIPTION": "📱 Подписка", - "MENU_SUPPORT": "🛠️ Техподдержка", - "MENU_TRIAL": "🧪 Тестовая подписка", - "MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.", - "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Оплата через Mulen Pay\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", - "MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay", - "MULENPAY_TOPUP_PROMPT": "💳 Оплата через Mulen Pay\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу Mulen Pay.", - "MY_BALANCE_BUTTON": "💰 Мой баланс", - "MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка", - "MY_TICKETS_BUTTON": "📋 Мои тикеты", - "MY_TICKETS_TITLE": "📋 Ваши тикеты:", - "NO": "❌ Нет", - "NOTIFICATION_CLOSED": "Уведомление закрыто.", - "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", - "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", - "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):", - "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", - "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):", - "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):", - "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):", - "NO_ATTACHMENTS": "Вложений нет.", - "NO_SERVERS_AVAILABLE": "❌ Нет доступных серверов", - "NO_TICKETS": "У вас пока нет тикетов.", - "NO_TICKETS_ADMIN": "Нет тикетов для отображения.", - "NO_TRAFFIC_PACKAGES": "❌ Нет доступных пакетов", - "OPEN_TICKETS": "🔴 Открытые", - "OPEN_TICKETS_HEADER": "🔴 Открытые тикеты", - "OPERATION_CANCELLED": "❌ Операция отменена", - "OTHER_APPS_BUTTON": "📋 Другие приложения", - "PAGINATION_NEXT": "➡️", - "PAGINATION_PREV": "⬅️", - "PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", - "PAL24_PAYMENT_INSTRUCTIONS": "💳 Оплата через PayPalych\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через PayPalych’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", - "PAL24_PAY_BUTTON": "💳 Оплатить через PayPalych", - "PAL24_TOPUP_PROMPT": "💳 Оплата через PayPalych\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через защищенную платформу PayPalych.", - "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны", - "PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)", - "PAYMENT_CARD_PAL24": "💳 Банковская карта (PayPalych)", - "PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)", - "PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)", - "PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)", - "PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:", - "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Способы пополнения баланса\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:", - "PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:", - "PAYMENT_METHODS_TITLE": "💳 Способы пополнения баланса", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", - "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", - "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", - "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay", - "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банковская карта (Mulen Pay)", - "PAYMENT_METHOD_PAL24_DESCRIPTION": "через PayPalych", - "PAYMENT_METHOD_PAL24_NAME": "💳 Банковская карта (PayPalych)", - "PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно", - "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", - "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", - "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", - "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", - "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банковская карта", - "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", - "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банковская карта", - "PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)", - "PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars", - "PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку", - "PAY_NOW_BUTTON": "💳 Оплатить", - "PAY_WITH_COINS_BUTTON": "🪙 Оплатить", - "PENDING_CANCEL_BUTTON": "⌛ Отмена", - "PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}", - "PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}", - "PERIOD_30_DAYS": "📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}", - "PERIOD_360_DAYS": "📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}", - "PERIOD_60_DAYS": "📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}", - "PERIOD_90_DAYS": "📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}", - "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Подключиться бесплатно 🚀", - "PROMOCODE_EMPTY_INPUT": "❌ Введите корректный промокод", - "PROMOCODE_ENTER": "🎫 Введите промокод:", - "PROMOCODE_EXPIRED": "❌ Промокод истек", - "PROMOCODE_INVALID": "❌ Неверный промокод", - "PROMOCODE_SUCCESS": "🎉 Промокод активирован! {description}", - "PROMOCODE_USED": "❌ Промокод уже использован", - "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Скидки вашей промогруппы", - "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%", - "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%", - "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%", - "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:", - "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", - "REFERRAL_ANALYTICS_BUTTON": "📊 Аналитика", - "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Доходы по периодам:", - "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• За месяц: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• За квартал: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Сегодня: {amount}", - "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• За неделю: {amount}", - "REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!", - "REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов", - "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)", - "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Топ-{count} рефералов:", - "REFERRAL_CODE_ACCEPTED": "✅ Реферальный код принят!", - "REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.", - "REFERRAL_CODE_INVALID": "❌ Неверный реферальный код", - "REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start", - "REFERRAL_CODE_QUESTION": "\n🤝 У вас есть реферальный код от друга?\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n", - "REFERRAL_CODE_SKIP": "⏭️ Пропустить", - "REFERRAL_CODE_TITLE": "🆔 Ваш код: {code}", - "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Доходы по типам:", - "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Бонусы за первые пополнения: {count} ({amount})", - "REFERRAL_EARNINGS_PURCHASES": "• Комиссии с покупок: {count} ({amount})", - "REFERRAL_EARNINGS_TOPUPS": "• Комиссии с пополнений: {count} ({amount})", - "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Комиссия с покупки", - "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Комиссия с пополнения", - "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение", - "REFERRAL_INFO": "\n🤝 Реферальная программа\n\n👥 Приглашено: {referrals_count} друзей\n💰 Заработано: {earned_amount}\n\n🔗 Ваша реферальная ссылка:\n{referral_link}\n\n🎫 Ваш промокод:\n{referral_code}\n\n💰 Условия:\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n", - "REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!", - "REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:", - "REFERRAL_INVITE_CREATED_TITLE": "📝 Приглашение создано!", - "REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение", - "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита", - "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру", - "REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!", - "REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:", - "REFERRAL_INVITE_MESSAGE": "\n🎯 Приглашение в VPN сервис\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n", - "REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!", - "REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}", - "REFERRAL_LINK_TITLE": "🔗 Ваша реферальная ссылка:", - "REFERRAL_LIST_BUTTON": "👥 Список рефералов", - "REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!", - "REFERRAL_LIST_HEADER": "👥 Ваши рефералы (стр. {current}/{total})", - "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад", - "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно", - "REFERRAL_LIST_ITEM_EARNED": " 💎 Заработано с него: {amount}", - "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", - "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад", - "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Пополнений: {count}", - "REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️", - "REFERRAL_LIST_PREV_PAGE": "⬅️ Назад", - "REFERRAL_PROGRAM_TITLE": "👥 Реферальная программа", - "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Последние начисления:", - "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} от {referral_name}", - "REFERRAL_REWARDS_HEADER": "🎁 Как работают награды:", - "REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: {percent}%", - "REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: {bonus}", - "REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: {bonus} при первом пополнении от {minimum}", - "REFERRAL_SHARE_BUTTON": "📤 Поделиться", - "REFERRAL_STATS_ACTIVE": "• Активных рефералов: {count}", - "REFERRAL_STATS_CONVERSION": "• Конверсия: {rate}%", - "REFERRAL_STATS_FIRST_TOPUPS": "• Сделали первое пополнение: {count}", - "REFERRAL_STATS_HEADER": "📊 Ваша статистика:", - "REFERRAL_STATS_INVITED": "• Приглашено пользователей: {count}", - "REFERRAL_STATS_MONTH_EARNED": "• За последний месяц: {amount}", - "REFERRAL_STATS_TOTAL_EARNED": "• Заработано всего: {amount}", - "REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...", - "REPLY_TO_TICKET": "💬 Ответить", - "REPORT_CLOSE": "❌ Закрыть", - "REPORT_CLOSED": "✅ Отчет закрыт.", - "REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.", - "RESET_ALL_DEVICES_BUTTON": "🔄 Сбросить все устройства", - "RESET_DEVICE_CONFIRM_BUTTON": "✅ Да, сбросить это устройство", - "RESET_TRAFFIC_BUTTON": "🔄 Сбросить трафик", - "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки", - "RULES_ACCEPT": "✅ Принимаю правила", - "RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...", - "RULES_DECLINE": "❌ Не принимаю", - "RULES_HEADER": "📋 Правила сервиса", - "RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!", - "RULES_TEXT_DEFAULT": "📋 Правила использования сервиса\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\nИспользуя сервис, вы подтверждаете согласие с этими правилами.", - "SELECT_COUNTRIES": "Выберите страны:", - "SELECT_DEVICES": "Количество устройств:", - "SELECT_PERIOD": "Выберите период:", - "SELECT_TRAFFIC": "Выберите пакет трафика:", - "SENDING_ATTACHMENTS": "📎 Отправляю вложения...", - "SEND_CONTACT_BUTTON": "📱 Отправить контакт", - "SEND_LOCATION_BUTTON": "📍 Отправить геолокацию", - "SHOW_QR_BUTTON": "📱 Показать QR код", - "SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки", - "SKIP_BUTTON": "⏭️ Пропустить", - "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.", - "STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.", - "STARS_PAYMENT_SUCCESS": "🎉 Платеж успешно обработан!\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀", - "STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.", - "STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.", - "STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.", - "STARS_PRECHECK_USER_NOT_FOUND": "Пользователь не найден. Обратитесь в поддержку.", - "SUBSCRIPTION_ACTIVE": "✅ Активна", - "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", - "SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:", - "SUBSCRIPTION_APPS_TITLE": "📱 Приложения для {device_name}", - "SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено", - "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", - "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Подключенные устройства:\n", - "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", - "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", - "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", - "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", - "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Ссылка для подключения:\n{subscription_url}", - "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", - "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", - "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", - "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку", - "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу", - "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 Как подключить:", - "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", - "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Шаг 2 - Добавление подписки:", - "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Шаг 3 - Подключение:", - "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", - "SUBSCRIPTION_EXPIRED": "\n❌ Подписка истекла\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n", - "SUBSCRIPTION_EXPIRED_1D": "⛔ Подписка закончилась\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}", - "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.", - "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.", - "SUBSCRIPTION_EXPIRING": "\n⚠️ Подписка истекает!\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n", - "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Подписка истекает через {days_text}!\n\nВаша платная подписка истекает {end_date}.\n\n💳 Автоплатеж: {autopay_status}\n\n{action_text}\n", - "SUBSCRIPTION_EXTEND": "💎 Продлить подписку", - "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", - "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.", - "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", - "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Открыть ссылку в Happ", - "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Подключение через Happ", - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", - "SUBSCRIPTION_INFO": "\n📱 Информация о подписке\n\n📊 Статус: {status}\n🎭 Тип: {type}\n📅 Действует до: {end_date}\n⏰ Осталось дней: {days_left}\n\n📈 Трафик: {traffic_used} / {traffic_limit}\n🌍 Серверы: {countries_count} стран\n📱 Устройства: {devices_used} / {devices_limit}\n\n💳 Автоплатеж: {autopay_status}\n", - "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", - "SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", - "SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать", - "SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение", - "SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"", - "SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку", - "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", - "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 Как использовать:", - "SUBSCRIPTION_NONE": "❌ Нет активной подписки", - "SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена", - "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", - "SUBSCRIPTION_NO_SERVERS": "Нет серверов", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", - "SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!", - "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки", - "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", - "SUBSCRIPTION_STATUS_ACTIVE": "Активна", - "SUBSCRIPTION_STATUS_EXPIRED": "Истекла", - "SUBSCRIPTION_STATUS_TRIAL": "Тестовая", - "SUBSCRIPTION_STATUS_UNKNOWN": "Неизвестно", - "SUBSCRIPTION_SUMMARY": "\n📋 Итоговая конфигурация\n\n📅 Период: {period} дней\n📈 Трафик: {traffic}\n🌍 Страны: {countries}\n📱 Устройства: {devices}\n\n💰 Итого к оплате: {total_price}\n\nПодтвердить покупку?\n", - "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} дн.", - "SUBSCRIPTION_TIME_LEFT_EXPIRED": "истёк", - "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} ч.", - "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} мин.", - "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} ГБ", - "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (безлимит) | Использовано: {used} ГБ", - "SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка", - "SUBSCRIPTION_TYPE_PAID": "Платная", - "SUBSCRIPTION_TYPE_TRIAL": "Триал", - "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 истекает через несколько минут!", - "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ истекает сегодня!", - "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ истекает завтра!", - "SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Активна\n⚠️ истекает через {days} дн.", - "SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)", - "SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!", - "SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!", - "SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}", - "SUB_STATUS_NONE": "❌ Отсутствует", - "SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)", - "SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!", - "SUB_STATUS_TRIAL_TOMORROW": "🎁 Тестовая подписка\n⚠️ истекает завтра!", - "SUCCESS": "✅ Успешно", - "SUPPORT_BUTTON": "🆘 Поддержка", - "SUPPORT_INFO": "\n🛠️ Техническая поддержка\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n", - "SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик", - "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Подтверждение переключения трафика\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n", - "SWITCH_TRAFFIC_INFO": "\n🔄 Переключение лимита трафика\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 Важно:\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n", - "SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Лимит трафика уменьшен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\nℹ️ Возврат средств не производится\n", - "SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Лимит трафика увеличен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\n💰 Списано: {amount}\n", - "SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика", - "TICKET_ATTACHMENTS": "📎 Вложения", - "TICKET_CLOSED": "✅ Тикет закрыт.", - "TICKET_CLOSE_ERROR": "❌ Ошибка при закрытии тикета.", - "TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.", - "TICKET_CREATION_CANCELLED": "Создание тикета отменено.", - "TICKET_CREATION_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.", - "TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.", - "TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото c подписью:", - "TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:", - "TICKET_NOT_FOUND": "Тикет не найден.", - "TICKET_PRIORITY_HIGH": "🟠 Высокий", - "TICKET_PRIORITY_LOW": "🟢 Низкий", - "TICKET_PRIORITY_NORMAL": "🟡 Обычный", - "TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:", - "TICKET_PRIORITY_URGENT": "🔴 Срочный", - "TICKET_REPLY_CANCELLED": "Ответ отменен.", - "TICKET_REPLY_ERROR": "❌ Произошла ошибка при отправке ответа. Попробуйте позже.", - "TICKET_REPLY_INPUT": "Введите ваш ответ:", - "TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:", - "TICKET_REPLY_SENT": "✅ Ваш ответ отправлен!", - "TICKET_REPLY_TOO_SHORT": "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:", - "TICKET_STATUS_ANSWERED": "Отвечен", - "TICKET_STATUS_CLOSED": "Закрыт", - "TICKET_STATUS_OPEN": "Открыт", - "TICKET_STATUS_PENDING": "В ожидании", - "TICKET_TITLE_INPUT": "Введите заголовок тикета:", - "TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:", - "TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:", - "TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.", - "TOPUP_BALANCE_BUTTON": "💳 Попол\\у043Dить баланс", - "TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):", - "TOP_UP_METHODS": "\n💳 Выберите способ оплаты\n\nСумма: {amount}\n", - "TOP_UP_STARS": "⭐ Telegram Stars", - "TOP_UP_TRIBUTE": "💎 Банковская карта", - "TRAFFIC_100GB": "📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", - "TRAFFIC_10GB": "📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", - "TRAFFIC_250GB": "📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", - "TRAFFIC_25GB": "📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}", - "TRAFFIC_50GB": "📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}", - "TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", - "TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}", - "TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился", - "TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены", - "TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", - "TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!", - "TRIAL_ACTIVATE_BUTTON": "🎁 Активировать", - "TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована", - "TRIAL_AVAILABLE": "\n🎁 Тестовая подписка\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ Период: {days} дней\n📈 Трафик: {traffic} ГБ\n📱 Устройства: {devices} шт.\n🌍 Сервер: {server_name}\n\nАктивировать тестовую подписку?\n", - "TRIAL_ENDING_SOON": "\n🎁 Тестовая подписка скоро закончится!\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 Не хотите остаться без VPN?\nПереходите на полную подписку!\n\n🔥 Специальное предложение:\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n", - "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!", - "TRIAL_INACTIVE_24H": "⏳ Прошли сутки с начала теста\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!", - "UNBLOCK": "✅ Разблокировать", - "UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.", - "UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.", - "USER_NOT_FOUND": "❌ Пользователь не найден", - "VIEW_TICKET": "👁️ Посмотреть тикет", - "WELCOME": "\n🎉 Добро пожаловать в VPN сервис!\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 Преимущества:\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n", - "WELCOME_FALLBACK": "Добро пожаловать, {user_name}!", - "YES": "✅ Да" + "ACCESS_DENIED": "❌ Доступ запрещен", + "ADD_COUNTRIES_BUTTON": "🌐 Добавить страны", + "ADMIN_MAIN_MENU": "🏠 Главное меню", + "ADMIN_CAMPAIGNS": "📣 Рекламные кампании", + "ADMIN_MESSAGES": "📨 Рассылки", + "ADMIN_MONITORING": "🔍 Мониторинг", + "ADMIN_MONITORING_SETTINGS": "⚙️ Настройки мониторинга", + "ADMIN_REPORTS": "📊 Отчеты", + "ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n", + "ADMIN_PROMOCODES": "🎫 Промокоды", + "ADMIN_REFERRALS": "🤝 Партнерка", + "ADMIN_REMNAWAVE": "🖥️ Remnawave", + "ADMIN_RULES": "📋 Правила", + "ADMIN_STATISTICS": "📊 Статистика", + "ADMIN_PROMO_GROUPS": "💳 Промогруппы", + "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", + "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", + "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", + "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", + "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", + "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", + "CREATE_TICKET_BUTTON": "🎫 Создать тикет", + "MY_TICKETS_BUTTON": "📋 Мои тикеты", + "CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой", + "SUPPORT_BUTTON": "🆘 Поддержка", + "TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:", + "TICKET_PRIORITY_LOW": "🟢 Низкий", + "TICKET_PRIORITY_NORMAL": "🟡 Обычный", + "TICKET_PRIORITY_HIGH": "🟠 Высокий", + "TICKET_PRIORITY_URGENT": "🔴 Срочный", + "CANCEL_TICKET_CREATION": "❌ Отменить создание тикета", + "TICKET_TITLE_INPUT": "Введите заголовок тикета:", + "TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:", + "TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:", + "TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото c подписью:", + "TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:", + "TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.", + "VIEW_TICKET": "👁️ Посмотреть тикет", + "BACK_TO_MENU": "🏠 В главное меню", + "TICKET_CREATION_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.", + "NO_TICKETS": "У вас пока нет тикетов.", + "MY_TICKETS_TITLE": "📋 Ваши тикеты:", + "TICKET_STATUS_OPEN": "Открыт", + "TICKET_STATUS_ANSWERED": "Отвечен", + "TICKET_STATUS_CLOSED": "Закрыт", + "TICKET_STATUS_PENDING": "В ожидании", + "REPLY_TO_TICKET": "💬 Ответить", + "CLOSE_TICKET": "🔒 Закрыть тикет", + "CANCEL_REPLY": "❌ Отменить ответ", + "TICKET_REPLY_INPUT": "Введите ваш ответ:", + "TICKET_REPLY_TOO_SHORT": "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:", + "TICKET_REPLY_SENT": "✅ Ваш ответ отправлен!", + "TICKET_REPLY_ERROR": "❌ Произошла ошибка при отправке ответа. Попробуйте позже.", + "TICKET_CLOSED": "✅ Тикет закрыт.", + "TICKET_CLOSE_ERROR": "❌ Ошибка при закрытии тикета.", + "TICKET_NOT_FOUND": "Тикет не найден.", + "TICKET_CREATION_CANCELLED": "Создание тикета отменено.", + "BACK_TO_SUPPORT": "⬅️ К поддержке", + "TICKET_REPLY_CANCELLED": "Ответ отменен.", + "BACK_TO_TICKETS": "⬅️ К тикетам", + "NO_TICKETS_ADMIN": "Нет тикетов для отображения.", + "ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:", + "ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:", + + "ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!", + "TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.", + "TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.", + "MARK_AS_ANSWERED": "✅ Отметить как отвеченный", + "TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:", + "CLOSE_NOTIFICATION": "❌ Закрыть уведомление", + "REPORT_CLOSE": "❌ Закрыть", + "REPORT_CLOSED": "✅ Отчет закрыт.", + "REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.", + "NOTIFICATION_CLOSED": "Уведомление закрыто.", + "UNBLOCK": "✅ Разблокировать", + "BLOCK_FOREVER": "🚫 Заблокировать", + "BLOCK_BY_TIME": "⏳ Блокировка по времени", + "ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):", + "TICKET_ATTACHMENTS": "📎 Вложения", + "OPEN_TICKETS": "🔴 Открытые", + "CLOSED_TICKETS": "🟢 Закрытые", + "CLOSED_TICKETS_HEADER": "🟢 Закрытые тикеты", + "OPEN_TICKETS_HEADER": "🔴 Открытые тикеты", + "SENDING_ATTACHMENTS": "📎 Отправляю вложения...", + "NO_ATTACHMENTS": "Вложений нет.", + "ATTACHMENTS_SENT": "✅ Вложения отправлены.", + "DELETE_MESSAGE": "🗑 Удалить", + "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа", + "ADMIN_USER_PROMO_GROUP_TITLE": "👥 Промогруппа пользователя", + "ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}", + "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.", + "ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:", + "ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»", + "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.", + "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.", + "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", + "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}", + "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", + "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", + "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", + "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", + "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить", + "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:", + "ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.", + "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", + "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", + "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", + "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", + "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", + "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", + "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", + "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100). Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100). Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100). Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", + "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽", + "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»", + "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:", + "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название", + "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик", + "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", + "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", + "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", + "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", + "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", + "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", + "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.", + "ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.", + "ADMIN_SUBSCRIPTIONS": "📱 Подписки", + "ADMIN_USERS": "👥 Пользователи", + "AUTOPAY_BUTTON": "💳 Автоплатёж", + "AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!", + "AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически", + "AUTOPAY_FAILED": "\n❌ Ошибка автоплатежа\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n", + "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни", + "AUTOPAY_SUCCESS": "\n✅ Автоплатеж выполнен\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n", + "BACK": "⬅️ Назад", + "BACK_TO_SUBSCRIPTION": "⬅️ К подписке", + "BALANCE_BUTTON": "💰 Баланс: {balance}", + "BALANCE_BUTTON_DEFAULT": "💰 Баланс: {balance}", + "BALANCE_BUTTON_ZERO": "💰 Баланс: 0 ₽", + "BALANCE_HISTORY": "📊 История операций", + "BALANCE_INFO": "\n💰 Баланс: {balance}\n\nВыберите действие:\n", + "BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку", + "BALANCE_TOP_UP": "💳 Пополнить", + "BALANCE_TOPUP": "💳 Пополнить баланс", + "CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.", + "CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!", + "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!", + "BUY_SUBSCRIPTION_START": "\n💎 Настройка подписки\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n", + "PROMO_GROUP_DISCOUNTS_HEADER": "🎁 Скидки вашей промогруппы", + "PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%", + "PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%", + "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%", + "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:", + "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", + "CANCEL": "❌ Отмена", + "CHANGE_DEVICES_BUTTON": "📱 Изменить устройства", + "CHANGE_DEVICES_CONFIRM": "\n 📱 Подтверждение изменения\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ", + "CHANGE_DEVICES_INFO": "\n 📱 Изменение количества устройств\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 Важно:\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ", + "CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ", + "CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ", + "CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств", + "CHANNEL_CHECK_BUTTON": "✅ Я подписался", + "CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.", + "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться", + "CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!", + "CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку", + "CHECK_STATUS_BUTTON": "📊 Проверить статус", + "CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство", + "CONFIRM": "✅ Подтвердить", + "CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение", + "CONNECT_BUTTON": "🔗 Подключиться", + "HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ", + "HAPP_DOWNLOAD_PROMPT": "📥 Скачать Happ\nВыберите ваше устройство:", + "HAPP_PLATFORM_IOS": "🍎 iOS", + "HAPP_PLATFORM_ANDROID": "🤖 Android", + "HAPP_PLATFORM_MACOS": "🖥️ Mac OS", + "HAPP_PLATFORM_WINDOWS": "💻 Windows", + "HAPP_PLATFORM_PC": "💻 ПК", + "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", + "HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена", + "HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку", + "CONTACT_SUPPORT": "💬 Написать в поддержку", + "CONTINUE": "➡️ Продолжить", + "CONTINUE_BUTTON": "✅ Продолжить", + "COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки", + "CREATE_INVITE": "📝 Создать приглашение", + "CREATE_INVITE_BUTTON": "📝 Создать приглашение", + "DEVICES_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}", + "DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})", + "DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}", + "DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось", + "DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?", + "DEVICE_GUIDE_ANDROID": "🤖 Android", + "DEVICE_GUIDE_ANDROID_TV": "📺 Android TV", + "DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)", + "DEVICE_GUIDE_MAC": "🎯 macOS", + "DEVICE_GUIDE_WINDOWS": "💻 Windows", + "DISABLE_BUTTON": "❌ Выключить", + "ENABLE_BUTTON": "✅ Включить", + "ERROR": "❌ Произошла ошибка", + "ERROR_TRY_AGAIN": "❌ Произошла ошибка. Попробуйте еще раз.", + "ERROR_RULES_RETRY": "Произошла ошибка. Попробуйте принять правила еще раз:", + "GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса", + "RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки", + "INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n Пополните баланс на {amount} и попробуйте снова.\n ", + "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Недостаточно средств\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.", + "INVALID_AMOUNT": "❌ Неверная сумма", + "LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: Русский", + "LOADING": "⏳ Загрузка...", + "MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n", + "MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n", + "MAIN_MENU": "👤 {user_name}\n \n📱 Подписка: {subscription_status}\n\nВыберите действие:\n", + "MAIN_MENU_ACTION_PROMPT": "Выберите действие:", + "MAIN_MENU_BUTTON": "🏠 Главное меню", + "MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами", + "MENU_ADMIN": "⚙️ Админ-панель", + "MENU_BALANCE": "💰 Баланс", + "MENU_BUY_SUBSCRIPTION": "💎 Купить подписку", + "MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку", + "MENU_LANGUAGE": "🌐 Язык", + "MENU_PROMOCODE": "🎫 Промокод", + "MENU_REFERRALS": "🤝 Партнерка", + "MENU_RULES": "📋 Правила сервиса", + "MENU_SUBSCRIPTION": "📱 Подписка", + "MENU_SUPPORT": "🛠️ Техподдержка", + "MENU_TRIAL": "🧪 Тестовая подписка", + "MY_BALANCE_BUTTON": "💰 Мой баланс", + "MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка", + "NO": "❌ Нет", + "NO_SERVERS_AVAILABLE": "❌ Нет доступных серверов", + "NO_TRAFFIC_PACKAGES": "❌ Нет доступных пакетов", + "OPERATION_CANCELLED": "❌ Операция отменена", + "OTHER_APPS_BUTTON": "📋 Другие приложения", + "PAGINATION_NEXT": "➡️", + "PAGINATION_PREV": "⬅️", + "PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны", + "PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)", + "PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)", + "PAYMENT_CARD_PAL24": "💳 Банковская карта (PayPalych)", + "PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)", + "PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)", + "PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)", + "PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars", + "PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку", + "PAY_NOW_BUTTON": "💳 Оплатить", + "PAY_WITH_COINS_BUTTON": "🪙 Оплатить", + "MULENPAY_TOPUP_PROMPT": "💳 Оплата через Mulen Pay\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу Mulen Pay.", + "MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.", + "MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay", + "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Оплата через Mulen Pay\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", + "PAL24_TOPUP_PROMPT": "💳 Оплата через PayPalych\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через защищенную платформу PayPalych.", + "PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + "PAL24_PAY_BUTTON": "💳 Оплатить через PayPalych", + "PAL24_PAYMENT_INSTRUCTIONS": "💳 Оплата через PayPalych\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через PayPalych’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", + "PENDING_CANCEL_BUTTON": "⌛ Отмена", + "PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}", + "PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}", + "PERIOD_30_DAYS": "📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}", + "PERIOD_360_DAYS": "📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}", + "PERIOD_60_DAYS": "📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}", + "PERIOD_90_DAYS": "📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}", + "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Подключиться бесплатно 🚀", + "PROMOCODE_ENTER": "🎫 Введите промокод:", + "PROMOCODE_EMPTY_INPUT": "❌ Введите корректный промокод", + "PROMOCODE_EXPIRED": "❌ Промокод истек", + "PROMOCODE_INVALID": "❌ Неверный промокод", + "PROMOCODE_SUCCESS": "🎉 Промокод активирован! {description}", + "PROMOCODE_USED": "❌ Промокод уже использован", + "REFERRAL_ANALYTICS_BUTTON": "📊 Аналитика", + "REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.", + "REFERRAL_CODE_ACCEPTED": "✅ Реферальный код принят!", + "REFERRAL_CODE_INVALID": "❌ Неверный реферальный код", + "REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start", + "REFERRAL_CODE_QUESTION": "\n🤝 У вас есть реферальный код от друга?\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n", + "REFERRAL_CODE_SKIP": "⏭️ Пропустить", + "ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", + "REFERRAL_INFO": "\n🤝 Реферальная программа\n\n👥 Приглашено: {referrals_count} друзей\n💰 Заработано: {earned_amount}\n\n🔗 Ваша реферальная ссылка:\n{referral_link}\n\n🎫 Ваш промокод:\n{referral_code}\n\n💰 Условия:\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n", + "REFERRAL_INVITE_MESSAGE": "\n🎯 Приглашение в VPN сервис\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n", + "REFERRAL_LIST_BUTTON": "👥 Список рефералов", + "RESET_ALL_DEVICES_BUTTON": "🔄 Сбросить все устройства", + "RESET_DEVICE_CONFIRM_BUTTON": "✅ Да, сбросить это устройство", + "RESET_TRAFFIC_BUTTON": "🔄 Сбросить трафик", + "RULES_ACCEPT": "✅ Принимаю правила", + "RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...", + "RULES_DECLINE": "❌ Не принимаю", + "RULES_HEADER": "📋 Правила сервиса", + "RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!", + "RULES_TEXT_DEFAULT": "📋 Правила использования сервиса\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\nИспользуя сервис, вы подтверждаете согласие с этими правилами.", + "SELECT_COUNTRIES": "Выберите страны:", + "SELECT_DEVICES": "Количество устройств:", + "SELECT_PERIOD": "Выберите период:", + "SELECT_TRAFFIC": "Выберите пакет трафика:", + "SEND_CONTACT_BUTTON": "📱 Отправить контакт", + "SEND_LOCATION_BUTTON": "📍 Отправить геолокацию", + "SHOW_QR_BUTTON": "📱 Показать QR код", + "SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки", + "SKIP_BUTTON": "⏭️ Пропустить", + "SUBSCRIPTION_ACTIVE": "✅ Активна", + "SUBSCRIPTION_EXTEND": "💎 Продлить подписку", + "SUBSCRIPTION_EXPIRED": "\n❌ Подписка истекла\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n", + "SUBSCRIPTION_EXPIRING": "\n⚠️ Подписка истекает!\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n", + "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Подписка истекает через {days_text}!\n\nВаша платная подписка истекает {end_date}.\n\n💳 Автоплатеж: {autopay_status}\n\n{action_text}\n", + "SUBSCRIPTION_INFO": "\n📱 Информация о подписке\n\n📊 Статус: {status}\n🎭 Тип: {type}\n📅 Действует до: {end_date}\n⏰ Осталось дней: {days_left}\n\n📈 Трафик: {traffic_used} / {traffic_limit}\n🌍 Серверы: {countries_count} стран\n📱 Устройства: {devices_used} / {devices_limit}\n\n💳 Автоплатеж: {autopay_status}\n", + "SUBSCRIPTION_NONE": "❌ Нет активной подписки", + "SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена", + "SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!", + "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки", + "SUBSCRIPTION_SUMMARY": "\n📋 Итоговая конфигурация\n\n📅 Период: {period} дней\n📈 Трафик: {traffic}\n🌍 Страны: {countries}\n📱 Устройства: {devices}\n\n💰 Итого к оплате: {total_price}\n\nПодтвердить покупку?\n", + "SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка", + "SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Активна\n⚠️ истекает через {days} дн.", + "SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)", + "SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!", + "SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!", + "SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}", + "SUB_STATUS_NONE": "❌ Отсутствует", + "SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)", + "SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!", + "SUB_STATUS_TRIAL_TOMORROW": "🎁 Тестовая подписка\n⚠️ истекает завтра!", + "SUCCESS": "✅ Успешно", + "REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...", + "SUPPORT_INFO": "\n🛠️ Техническая поддержка\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n", + "SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик", + "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Подтверждение переключения трафика\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n", + "SWITCH_TRAFFIC_INFO": "\n🔄 Переключение лимита трафика\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 Важно:\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n", + "SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Лимит трафика уменьшен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\nℹ️ Возврат средств не производится\n", + "SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Лимит трафика увеличен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\n💰 Списано: {amount}\n", + "SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика", + "TOPUP_BALANCE_BUTTON": "💳 Попол\\у043Dить баланс", + "TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):", + "TOP_UP_METHODS": "\n💳 Выберите способ оплаты\n\nСумма: {amount}\n", + "TOP_UP_STARS": "⭐ Telegram Stars", + "STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.", + "STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.", + "STARS_PAYMENT_SUCCESS": "🎉 Платеж успешно обработан!\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀", + "STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.", + "STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.", + "STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.", + "STARS_PRECHECK_USER_NOT_FOUND": "Пользователь не найден. Обратитесь в поддержку.", + "TOP_UP_TRIBUTE": "💎 Банковская карта", + "TRAFFIC_100GB": "📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", + "TRAFFIC_10GB": "📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", + "TRAFFIC_250GB": "📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", + "TRAFFIC_25GB": "📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}", + "TRAFFIC_50GB": "📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}", + "TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", + "TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}", + "TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился", + "TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены", + "TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", + "TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!", + "TRIAL_ACTIVATE_BUTTON": "🎁 Активировать", + "TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована", + "TRIAL_AVAILABLE": "\n🎁 Тестовая подписка\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ Период: {days} дней\n📈 Трафик: {traffic} ГБ\n📱 Устройства: {devices} шт.\n🌍 Сервер: {server_name}\n\nАктивировать тестовую подписку?\n", + "TRIAL_ENDING_SOON": "\n🎁 Тестовая подписка скоро закончится!\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 Не хотите остаться без VPN?\nПереходите на полную подписку!\n\n🔥 Специальное предложение:\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n", + "UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.", + "UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.", + "USER_NOT_FOUND": "❌ Пользователь не найден", + "WELCOME": "\n🎉 Добро пожаловать в VPN сервис!\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 Преимущества:\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n", + "WELCOME_FALLBACK": "Добро пожаловать, {user_name}!", + "YES": "✅ Да", + "SUBSCRIPTION_STATUS_EXPIRED": "Истекла", + "SUBSCRIPTION_STATUS_TRIAL": "Тестовая", + "SUBSCRIPTION_STATUS_ACTIVE": "Активна", + "SUBSCRIPTION_STATUS_UNKNOWN": "Неизвестно", + "SUBSCRIPTION_TIME_LEFT_EXPIRED": "истёк", + "SUBSCRIPTION_TIME_LEFT_DAYS": "{days} дн.", + "SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} ч.", + "SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} мин.", + "SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ истекает завтра!", + "SUBSCRIPTION_WARNING_TODAY": "\n⚠️ истекает сегодня!", + "SUBSCRIPTION_WARNING_MINUTES": "\n🔴 истекает через несколько минут!", + "SUBSCRIPTION_TYPE_TRIAL": "Триал", + "SUBSCRIPTION_TYPE_PAID": "Платная", + "SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (безлимит) | Использовано: {used} ГБ", + "SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} ГБ", + "SUBSCRIPTION_NO_SERVERS": "Нет серверов", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Подключенные устройства:\n", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", + "SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 Ссылка для подключения:\n{subscription_url}", + "SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", + "SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню", + "CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена", + "SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", + "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", + "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 Подключить подписку\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:", + "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", + "SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 Подключить подписку\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:", + "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", + "SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна", + "SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены", + "SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 Настройка для {device_name}", + "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Подключение через Happ", + "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Открыть ссылку в Happ", + "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную: {subscription_link}", + "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.", + "SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 Ссылка подписки:", + "SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 Рекомендуемое приложение: {app_name}", + "SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "Шаг 1 - Установка:", + "SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "Шаг 2 - Добавление подписки:", + "SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "Шаг 3 - Подключение:", + "SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 Как подключить:", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку", + "SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу", + "SUBSCRIPTION_APPS_TITLE": "📱 Приложения для {device_name}", + "SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:", + "SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено", + "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", + "SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "{title}:", + "SUBSCRIPTION_LINK_USAGE_TITLE": "📱 Как использовать:", + "SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать", + "SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение", + "SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"", + "SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку", + "SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", + "REFERRAL_PROGRAM_TITLE": "👥 Реферальная программа", + "REFERRAL_STATS_HEADER": "📊 Ваша статистика:", + "REFERRAL_STATS_INVITED": "• Приглашено пользователей: {count}", + "REFERRAL_STATS_FIRST_TOPUPS": "• Сделали первое пополнение: {count}", + "REFERRAL_STATS_ACTIVE": "• Активных рефералов: {count}", + "REFERRAL_STATS_CONVERSION": "• Конверсия: {rate}%", + "REFERRAL_STATS_TOTAL_EARNED": "• Заработано всего: {amount}", + "REFERRAL_STATS_MONTH_EARNED": "• За последний месяц: {amount}", + "REFERRAL_REWARDS_HEADER": "🎁 Как работают награды:", + "REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: {bonus} при первом пополнении от {minimum}", + "REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: {bonus}", + "REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: {percent}%", + "REFERRAL_LINK_TITLE": "🔗 Ваша реферальная ссылка:", + "REFERRAL_CODE_TITLE": "🆔 Ваш код: {code}", + "REFERRAL_RECENT_EARNINGS_HEADER": "💰 Последние начисления:", + "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение", + "REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Комиссия с пополнения", + "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Комиссия с покупки", + "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} от {referral_name}", + "REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 Доходы по типам:", + "REFERRAL_EARNINGS_FIRST_TOPUPS": "• Бонусы за первые пополнения: {count} ({amount})", + "REFERRAL_EARNINGS_TOPUPS": "• Комиссии с пополнений: {count} ({amount})", + "REFERRAL_EARNINGS_PURCHASES": "• Комиссии с покупок: {count} ({amount})", + "REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!", + "REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}", + "REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!", + "REFERRAL_LIST_HEADER": "👥 Ваши рефералы (стр. {current}/{total})", + "REFERRAL_LIST_ITEM_HEADER": "{index}. {status} {name}", + "REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Пополнений: {count}", + "REFERRAL_LIST_ITEM_EARNED": " 💎 Заработано с него: {amount}", + "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад", + "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад", + "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно", + "REFERRAL_LIST_PREV_PAGE": "⬅️ Назад", + "REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️", + "REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов", + "REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 Доходы по периодам:", + "REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Сегодня: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_WEEK": "• За неделю: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_MONTH": "• За месяц: {amount}", + "REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• За квартал: {amount}", + "REFERRAL_ANALYTICS_TOP_TITLE": "🏆 Топ-{count} рефералов:", + "REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)", + "REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!", + "REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!", + "REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!", + "REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение", + "REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру", + "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита", + "REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:", + "REFERRAL_SHARE_BUTTON": "📤 Поделиться", + "REFERRAL_INVITE_CREATED_TITLE": "📝 Приглашение создано!", + "REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:", + "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Способы пополнения баланса\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:", + "PAYMENT_METHODS_TITLE": "💳 Способы пополнения баланса", + "PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:", + "PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:", + "PAYMENT_METHOD_STARS_NAME": "⭐ Telegram Stars", + "PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно", + "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банковская карта", + "PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa", + "PAYMENT_METHOD_TRIBUTE_NAME": "💳 Банковская карта", + "PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute", + "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банковская карта (Mulen Pay)", + "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay", + "PAYMENT_METHOD_PAL24_NAME": "💳 Банковская карта (PayPalych)", + "PAYMENT_METHOD_PAL24_DESCRIPTION": "через PayPalych", + "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", + "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", + "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", + "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", + "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!", + "TRIAL_INACTIVE_24H": "⏳ Прошли сутки с начала теста\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Подписка закончилась\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", + "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", + "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", + "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", + "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", + "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", + "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):", + "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):" } From 461b820ef1575c7c68e72665363f68df552fc884 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 13:18:40 +0300 Subject: [PATCH 114/146] feat: localize promo group addon discount toggle --- app/database/crud/promo_group.py | 9 +- app/database/crud/subscription.py | 27 +++- app/database/models.py | 20 ++- app/database/universal_migration.py | 36 +++++ app/handlers/admin/promo_groups.py | 160 ++++++++++++++++++++++ app/handlers/subscription.py | 198 +++++++++++++++++++++++---- app/keyboards/inline.py | 168 ++++++++++++++++++----- app/services/subscription_service.py | 27 +++- app/states.py | 2 + locales/en.json | 7 + locales/ru.json | 7 + 11 files changed, 596 insertions(+), 65 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 3bc093f2..d5d781ae 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -60,6 +60,7 @@ async def create_promo_group( device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + apply_discounts_to_addons: bool = False, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) @@ -76,6 +77,7 @@ async def create_promo_group( device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, + apply_discounts_to_addons=bool(apply_discounts_to_addons), is_default=False, ) @@ -84,13 +86,15 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽," + " скидки на доп. услуги: %s", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, normalized_period_discounts, (auto_assign_total_spent_kopeks or 0) / 100, + "включены" if promo_group.apply_discounts_to_addons else "выключены", ) return promo_group @@ -106,6 +110,7 @@ async def update_promo_group( device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + apply_discounts_to_addons: Optional[bool] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -120,6 +125,8 @@ async def update_promo_group( group.period_discounts = normalized_period_discounts or None if auto_assign_total_spent_kopeks is not None: group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) + if apply_discounts_to_addons is not None: + group.apply_discounts_to_addons = bool(apply_discounts_to_addons) await db.commit() await db.refresh(group) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 91b79375..ffe62aca 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -504,15 +504,35 @@ def _get_discount_percent( category: str, *, period_days: Optional[int] = None, + for_addon: bool = False, ) -> int: + effective_group = promo_group + + if user is not None and effective_group is None: + effective_group = getattr(user, "promo_group", None) + + if for_addon: + if user is not None: + try: + return user.get_addon_discount(category, period_days) + except AttributeError: + pass + + if effective_group is not None: + try: + return effective_group.get_addon_discount_percent(category, period_days) + except AttributeError: + return 0 + return 0 + if user is not None: try: return user.get_promo_discount(category, period_days) except AttributeError: pass - if promo_group is not None: - return promo_group.get_discount_percent(category, period_days) + if effective_group is not None: + return effective_group.get_discount_percent(category, period_days) return 0 @@ -852,6 +872,7 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "traffic", period_days=period_hint_days, + for_addon=True, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month @@ -873,6 +894,7 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "devices", period_days=period_hint_days, + for_addon=True, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month @@ -902,6 +924,7 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "servers", period_days=period_hint_days, + for_addon=True, ) server_discount_per_month = server_price_per_month * servers_discount_percent // 100 discounted_server_per_month = server_price_per_month - server_discount_per_month diff --git a/app/database/models.py b/app/database/models.py index 0a3ad865..5f04998c 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -292,6 +292,7 @@ class PromoGroup(Base): device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) + apply_discounts_to_addons = Column(Boolean, nullable=False, default=False) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -359,6 +360,15 @@ class PromoGroup(Base): return max(0, min(100, percent)) + def get_addon_discount_percent( + self, + category: str, + period_days: Optional[int] = None, + ) -> int: + if not self.apply_discounts_to_addons: + return 0 + return self.get_discount_percent(category, period_days) + class User(Base): __tablename__ = "users" @@ -408,7 +418,15 @@ class User(Base): if not self.promo_group: return 0 return self.promo_group.get_discount_percent(category, period_days) - + + def get_addon_discount(self, category: str, period_days: Optional[int] = None) -> int: + if not self.promo_group: + return 0 + try: + return self.promo_group.get_addon_discount_percent(category, period_days) + except AttributeError: + return 0 + def add_balance(self, kopeks: int) -> None: self.balance_kopeks += kopeks diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index b123c750..6959b582 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -931,6 +931,39 @@ async def ensure_promo_groups_setup(): "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" ) + addon_discount_column_exists = await check_column_exists( + "promo_groups", "apply_discounts_to_addons" + ) + + if not addon_discount_column_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons INTEGER NOT NULL DEFAULT 0" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT FALSE" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons TINYINT(1) NOT NULL DEFAULT 0" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.apply_discounts_to_addons: {db_type}" + ) + return False + + logger.info( + "Добавлена колонка promo_groups.apply_discounts_to_addons" + ) + column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -1994,6 +2027,7 @@ async def check_migration_status(): "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, + "promo_groups_addon_discount_column": False, "users_auto_promo_group_assigned_column": False, "subscription_crypto_link_column": False, } @@ -2011,6 +2045,7 @@ async def check_migration_status(): status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') + status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') @@ -2048,6 +2083,7 @@ async def check_migration_status(): "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", + "promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 917f673f..381ebd8d 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -39,6 +39,23 @@ def _format_discount_line(texts, group) -> str: ) +def _format_addon_discount_status(texts, enabled: bool) -> str: + return ( + texts.t("ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ON", "включены") + if enabled + else texts.t("ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_OFF", "отключены") + ) + + +def _format_addon_discount_line(texts, group: PromoGroup) -> str: + enabled = bool(getattr(group, "apply_discounts_to_addons", False)) + status = _format_addon_discount_status(texts, enabled) + return texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_LINE", + "Скидки на доп. услуги: {status}", + ).format(status=status) + + def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: if not raw or not isinstance(raw, dict): return {} @@ -223,6 +240,39 @@ def _parse_auto_assign_threshold_input(value: str) -> int: return max(0, kopeks) +def _parse_boolean_choice(value: str) -> bool: + cleaned = (value or "").strip().lower() + + if cleaned in {"1", "да", "yes", "y", "true", "on", "+"}: + return True + if cleaned in {"0", "нет", "no", "n", "false", "off", "-"}: + return False + + raise ValueError + + +async def _prompt_for_addon_discount_toggle( + message: types.Message, + state: FSMContext, + prompt_key: str, + default_text: str, + *, + current_value: Optional[bool] = None, +): + data = await state.get_data() + texts = get_texts(data.get("language", "ru")) + prompt_text = texts.t(prompt_key, default_text) + + if current_value is not None: + status = _format_addon_discount_status(texts, current_value) + try: + prompt_text = prompt_text.format(current=status) + except KeyError: + prompt_text = f"{prompt_text} ({status})" + + await message.answer(prompt_text) + + async def _prompt_for_auto_assign_threshold( message: types.Message, state: FSMContext, @@ -257,6 +307,7 @@ def _build_edit_menu_content( lines = [ header, _format_discount_line(texts, group), + _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), ] @@ -309,6 +360,15 @@ def _build_edit_menu_content( callback_data=f"promo_group_edit_field_{group.id}_devices", ) ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDONS", + "🎁 Скидки на доп. услуги", + ), + callback_data=f"promo_group_edit_field_{group.id}_addons", + ) + ], [ types.InlineKeyboardButton( text=texts.t( @@ -399,6 +459,7 @@ async def show_promo_groups_menu( group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), + _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", @@ -474,6 +535,7 @@ async def show_promo_group_details( "💳 Промогруппа: {name}", ).format(name=group.name), _format_discount_line(texts, group), + _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", @@ -707,6 +769,41 @@ async def process_create_group_auto_assign( ) return + await state.update_data(new_group_auto_assign=auto_assign_kopeks) + await state.set_state(AdminStates.creating_promo_group_addon_discount) + + await _prompt_for_addon_discount_toggle( + message, + state, + "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT", + "Включить скидки на докупку доп. услуг? (да/нет)", + ) + + +@admin_required +@error_handler +async def process_create_group_addon_discount( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + addons_enabled = _parse_boolean_choice(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT", + "Введите «да» или «нет» для включения скидок на доп. услуги.", + ) + ) + return + + auto_assign_kopeks = data.get("new_group_auto_assign") + try: group = await create_promo_group( db, @@ -716,6 +813,7 @@ async def process_create_group_auto_assign( device_discount_percent=data["new_group_devices"], period_discounts=data.get("new_group_period_discounts"), auto_assign_total_spent_kopeks=auto_assign_kopeks, + apply_discounts_to_addons=addons_enabled, ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -819,6 +917,16 @@ async def prompt_edit_promo_group_field( "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT", "Введите новую скидку на устройства (текущее значение: {current}%):", ).format(current=group.device_discount_percent) + elif field == "addons": + await state.set_state(AdminStates.editing_promo_group_addon_discount) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_ADDONS_PROMPT", + "Включить скидки на доп. услуги? Текущее значение: {current}.", + ).format( + current=_format_addon_discount_status( + texts, getattr(group, "apply_discounts_to_addons", False) + ) + ) elif field == "periods": await state.set_state(AdminStates.editing_promo_group_period_discount) current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) @@ -1063,6 +1171,50 @@ async def process_edit_group_auto_assign( ) +@admin_required +@error_handler +async def process_edit_group_addon_discount( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + addons_enabled = _parse_boolean_choice(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT", + "Введите «да» или «нет» для включения скидок на доп. услуги.", + ) + ) + return + + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group( + db, + group, + apply_discounts_to_addons=addons_enabled, + ) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( + message, + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), + ) + + @admin_required @error_handler async def show_promo_group_members( @@ -1239,6 +1391,10 @@ def register_handlers(dp: Dispatcher): process_create_group_auto_assign, AdminStates.creating_promo_group_auto_assign, ) + dp.message.register( + process_create_group_addon_discount, + AdminStates.creating_promo_group_addon_discount, + ) dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name) dp.message.register( @@ -1261,3 +1417,7 @@ def register_handlers(dp: Dispatcher): process_edit_group_auto_assign, AdminStates.editing_promo_group_auto_assign, ) + dp.message.register( + process_edit_group_addon_discount, + AdminStates.editing_promo_group_addon_discount, + ) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 97b91d0b..5681f5d0 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -1142,11 +1142,12 @@ async def handle_add_countries( await callback.message.edit_text( text, reply_markup=get_manage_countries_keyboard( - countries, - current_countries.copy(), - current_countries, + countries, + current_countries.copy(), + current_countries, db_user.language, - subscription.end_date + subscription.end_date, + getattr(db_user, "promo_group", None), ), parse_mode="HTML" ) @@ -1232,10 +1233,11 @@ async def handle_manage_country( await callback.message.edit_reply_markup( reply_markup=get_manage_countries_keyboard( countries, - current_selected, - subscription.connected_squads, + current_selected, + subscription.connected_squads, db_user.language, - subscription.end_date + subscription.end_date, + getattr(db_user, "promo_group", None), ) ) logger.info(f"✅ Клавиатура обновлена") @@ -1251,7 +1253,11 @@ async def apply_countries_changes( db: AsyncSession, state: FSMContext ): - from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price + from app.utils.pricing_utils import ( + get_remaining_months, + calculate_prorated_price, + apply_percentage_discount, + ) logger.info(f"🔧 Применение изменений стран") @@ -1288,27 +1294,50 @@ async def apply_countries_changes( logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") months_to_pay = get_remaining_months(subscription.end_date) - + + promo_group = getattr(db_user, "promo_group", None) + addons_enabled = bool(promo_group and getattr(promo_group, "apply_discounts_to_addons", False)) + period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + servers_discount_percent = 0 + if addons_enabled: + try: + servers_discount_percent = promo_group.get_addon_discount_percent( + "servers", + period_days=period_hint_days, + ) + except AttributeError: + servers_discount_percent = 0 + + from app.utils.pricing_utils import apply_percentage_discount + cost_per_month = 0 added_names = [] removed_names = [] - + added_server_prices = [] - + for country in countries: if country['uuid'] in added: server_price_per_month = country['price_kopeks'] - cost_per_month += server_price_per_month + discounted_per_month, _ = apply_percentage_discount( + server_price_per_month, + servers_discount_percent, + ) + cost_per_month += discounted_per_month added_names.append(country['name']) if country['uuid'] in removed: removed_names.append(country['name']) - + total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) - + for country in countries: if country['uuid'] in added: server_price_per_month = country['price_kopeks'] - server_total_price = server_price_per_month * charged_months + discounted_per_month, _ = apply_percentage_discount( + server_price_per_month, + servers_discount_percent, + ) + server_total_price = discounted_per_month * charged_months added_server_prices.append(server_total_price) logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽") @@ -1454,7 +1483,11 @@ async def handle_add_traffic( f"📈 Добавить трафик к подписке\n\n" f"Текущий лимит: {texts.format_traffic(current_traffic)}\n" f"Выберите дополнительный трафик:", - reply_markup=get_add_traffic_keyboard(db_user.language, subscription.end_date), + reply_markup=get_add_traffic_keyboard( + db_user.language, + subscription.end_date, + getattr(db_user, "promo_group", None), + ), parse_mode="HTML" ) @@ -1482,7 +1515,12 @@ async def handle_change_devices( f"💡 Важно:\n" f"• При увеличении - доплата пропорционально оставшемуся времени\n" f"• При уменьшении - возврат средств не производится", - reply_markup=get_change_devices_keyboard(current_devices, db_user.language, subscription.end_date), + reply_markup=get_change_devices_keyboard( + current_devices, + db_user.language, + subscription.end_date, + getattr(db_user, "promo_group", None), + ), parse_mode="HTML" ) @@ -1493,7 +1531,11 @@ async def confirm_change_devices( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price + from app.utils.pricing_utils import ( + get_remaining_months, + calculate_prorated_price, + apply_percentage_discount, + ) new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) @@ -1524,7 +1566,31 @@ async def confirm_change_devices( chargeable_devices = additional_devices devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE - price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) + + promo_group = getattr(db_user, "promo_group", None) + addons_enabled = bool(promo_group and getattr(promo_group, "apply_discounts_to_addons", False)) + period_hint_days = None + if subscription.end_date: + months_remaining = get_remaining_months(subscription.end_date) + period_hint_days = months_remaining * 30 if months_remaining > 0 else None + devices_discount_percent = 0 + if addons_enabled: + try: + devices_discount_percent = promo_group.get_addon_discount_percent( + "devices", + period_days=period_hint_days, + ) + except AttributeError: + devices_discount_percent = 0 + + discounted_per_month, _ = apply_percentage_discount( + devices_price_per_month, + devices_discount_percent, + ) + price, charged_months = calculate_prorated_price( + discounted_per_month, + subscription.end_date, + ) if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks @@ -2139,9 +2205,45 @@ async def confirm_add_devices( return devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE - price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) - - logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽") + + promo_group = getattr(db_user, "promo_group", None) + addons_enabled = bool(promo_group and getattr(promo_group, "apply_discounts_to_addons", False)) + period_hint_days = None + if subscription.end_date: + months_remaining = get_remaining_months(subscription.end_date) + period_hint_days = months_remaining * 30 if months_remaining > 0 else None + devices_discount_percent = 0 + if addons_enabled: + try: + devices_discount_percent = promo_group.get_addon_discount_percent( + "devices", + period_days=period_hint_days, + ) + except AttributeError: + devices_discount_percent = 0 + + discounted_per_month, _ = apply_percentage_discount( + devices_price_per_month, + devices_discount_percent, + ) + price, charged_months = calculate_prorated_price( + discounted_per_month, + subscription.end_date, + ) + + if devices_discount_percent > 0: + logger.info( + "Добавление %s устройств: %s₽/мес → %s₽/мес × %s мес = %s₽", + devices_count, + devices_price_per_month / 100, + discounted_per_month / 100, + charged_months, + price / 100, + ) + else: + logger.info( + f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽" + ) if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks @@ -3502,11 +3604,23 @@ async def add_traffic( subscription = db_user.subscription price = settings.get_traffic_price(traffic_gb) - + if price == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) return - + + promo_group = getattr(db_user, "promo_group", None) + addons_enabled = bool(promo_group and getattr(promo_group, "apply_discounts_to_addons", False)) + if addons_enabled: + from app.utils.pricing_utils import apply_percentage_discount + + try: + discount_percent = promo_group.get_addon_discount_percent("traffic") + except AttributeError: + discount_percent = 0 + + price, _ = apply_percentage_discount(price, discount_percent) + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks message_text = texts.t( @@ -3925,11 +4039,34 @@ async def handle_add_country_to_subscription( selected_countries.append(country_uuid) logger.info(f"🔍 Добавлена страна: {country_uuid}") + from app.utils.pricing_utils import apply_percentage_discount + + subscription = db_user.subscription + months_multiplier = get_remaining_months(subscription.end_date) if subscription else 1 + period_hint_days = months_multiplier * 30 if months_multiplier > 0 else None + promo_group = getattr(db_user, "promo_group", None) + addons_enabled = bool(promo_group and getattr(promo_group, "apply_discounts_to_addons", False)) + servers_discount_percent = 0 + if addons_enabled: + try: + servers_discount_percent = promo_group.get_addon_discount_percent( + "servers", + period_days=period_hint_days, + ) + except AttributeError: + servers_discount_percent = 0 + total_price = 0 for country in countries: if country['uuid'] in selected_countries and country['uuid'] not in db_user.subscription.connected_squads: - total_price += country['price_kopeks'] - + price_per_month = country['price_kopeks'] + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + servers_discount_percent, + ) + discounted_total = discounted_per_month * months_multiplier + total_price += discounted_total + data['countries'] = selected_countries data['total_price'] = total_price await state.set_data(data) @@ -3940,7 +4077,14 @@ async def handle_add_country_to_subscription( try: from app.keyboards.inline import get_manage_countries_keyboard await callback.message.edit_reply_markup( - reply_markup=get_manage_countries_keyboard(countries, selected_countries, db_user.subscription.connected_squads, db_user.language) + reply_markup=get_manage_countries_keyboard( + countries, + selected_countries, + db_user.subscription.connected_squads, + db_user.language, + subscription.end_date if subscription else None, + promo_group, + ) ) logger.info(f"✅ Клавиатура обновлена") except Exception as e: diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index adb4462f..a39401d6 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import List, Optional, TYPE_CHECKING from aiogram import types from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from datetime import datetime @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts -from app.utils.pricing_utils import format_period_description +from app.utils.pricing_utils import format_period_description, apply_percentage_discount from app.utils.subscription_utils import ( get_display_subscription_link, get_happ_cryptolink_redirect_link, @@ -17,6 +17,9 @@ import logging logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from app.database.models import PromoGroup + def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ @@ -1123,21 +1126,30 @@ def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup: +def get_add_traffic_keyboard( + language: str = DEFAULT_LANGUAGE, + subscription_end_date: datetime = None, + promo_group: Optional["PromoGroup"] = None, +) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months from app.config import settings texts = get_texts(language) - + months_multiplier = 1 period_text = "" if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: period_text = f" (за {months_multiplier} мес)" - + + period_hint_days = months_multiplier * 30 if months_multiplier > 0 else None + addons_enabled = bool( + promo_group and getattr(promo_group, "apply_discounts_to_addons", False) + ) + packages = settings.get_traffic_packages() enabled_packages = [pkg for pkg in packages if pkg['enabled']] - + if not enabled_packages: return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton( @@ -1151,23 +1163,50 @@ def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_ ]) buttons = [] - + for package in enabled_packages: gb = package['gb'] price_per_month = package['price'] - total_price = price_per_month * months_multiplier - + original_total = price_per_month * months_multiplier + + discount_percent = 0 + if addons_enabled: + try: + discount_percent = promo_group.get_addon_discount_percent( + "traffic", + period_days=period_hint_days, + ) + except AttributeError: + discount_percent = 0 + + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + discount_percent, + ) + total_price = discounted_per_month * months_multiplier + total_discount = discount_per_month * months_multiplier + + if total_discount > 0: + price_display = f"{original_total//100} ₽ → {total_price//100} ₽" + else: + price_display = f"{total_price//100} ₽" + + if period_text and total_discount <= 0: + price_display += period_text + elif period_text and total_discount > 0: + price_display += period_text + if gb == 0: if language == "ru": - text = f"♾️ Безлимитный трафик - {total_price//100} ₽{period_text}" + text = f"♾️ Безлимитный трафик - {price_display}" else: - text = f"♾️ Unlimited traffic - {total_price//100} ₽{period_text}" + text = f"♾️ Unlimited traffic - {price_display}" else: if language == "ru": - text = f"📊 +{gb} ГБ трафика - {total_price//100} ₽{period_text}" + text = f"📊 +{gb} ГБ трафика - {price_display}" else: - text = f"📊 +{gb} GB traffic - {total_price//100} ₽{period_text}" - + text = f"📊 +{gb} GB traffic - {price_display}" + buttons.append([ InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}") ]) @@ -1181,20 +1220,29 @@ def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_ return InlineKeyboardMarkup(inline_keyboard=buttons) -def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup: +def get_change_devices_keyboard( + current_devices: int, + language: str = DEFAULT_LANGUAGE, + subscription_end_date: datetime = None, + promo_group: Optional["PromoGroup"] = None, +) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months from app.config import settings texts = get_texts(language) - + months_multiplier = 1 period_text = "" if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: period_text = f" (за {months_multiplier} мес)" - + device_price_per_month = settings.PRICE_PER_DEVICE - + period_hint_days = months_multiplier * 30 if months_multiplier > 0 else None + addons_enabled = bool( + promo_group and getattr(promo_group, "apply_discounts_to_addons", False) + ) + buttons = [] min_devices = 1 @@ -1211,15 +1259,37 @@ def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LA elif devices_count > current_devices: emoji = "➕" additional_devices = devices_count - current_devices - + current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT) new_chargeable = max(0, devices_count - settings.DEFAULT_DEVICE_LIMIT) chargeable_devices = new_chargeable - current_chargeable - + if chargeable_devices > 0: price_per_month = chargeable_devices * device_price_per_month - total_price = price_per_month * months_multiplier - price_text = f" (+{total_price//100}₽{period_text})" + discount_percent = 0 + if addons_enabled: + try: + discount_percent = promo_group.get_addon_discount_percent( + "devices", + period_days=period_hint_days, + ) + except AttributeError: + discount_percent = 0 + + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + discount_percent, + ) + total_price = discounted_per_month * months_multiplier + total_discount = discount_per_month * months_multiplier + + if total_discount > 0: + original_total = price_per_month * months_multiplier + price_display = f"+{original_total//100}₽ → {total_price//100}₽" + else: + price_display = f"+{total_price//100}₽" + + price_text = f" ({price_display}{period_text})" action_text = "" else: price_text = " (бесплатно)" @@ -1296,7 +1366,8 @@ def get_manage_countries_keyboard( selected: List[str], current_subscription_countries: List[str], language: str = DEFAULT_LANGUAGE, - subscription_end_date: datetime = None + subscription_end_date: datetime = None, + promo_group: Optional["PromoGroup"] = None ) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months @@ -1306,15 +1377,37 @@ def get_manage_countries_keyboard( if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) logger.info(f"🔍 Расчет для управления странами: осталось {months_multiplier} месяцев до {subscription_end_date}") - + + period_hint_days = months_multiplier * 30 if months_multiplier > 0 else None + addons_enabled = bool( + promo_group and getattr(promo_group, "apply_discounts_to_addons", False) + ) + buttons = [] total_cost = 0 - + for country in countries: uuid = country['uuid'] name = country['name'] price_per_month = country['price_kopeks'] - + + discount_percent = 0 + if addons_enabled: + try: + discount_percent = promo_group.get_addon_discount_percent( + "servers", + period_days=period_hint_days, + ) + except AttributeError: + discount_percent = 0 + + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + discount_percent, + ) + discounted_total = discounted_per_month * months_multiplier + discount_total = discount_per_month * months_multiplier + if uuid in current_subscription_countries: if uuid in selected: icon = "✅" @@ -1323,21 +1416,32 @@ def get_manage_countries_keyboard( else: if uuid in selected: icon = "➕" - total_cost += price_per_month * months_multiplier + total_cost += discounted_total else: icon = "⚪" - + if uuid not in current_subscription_countries and uuid in selected: - total_price = price_per_month * months_multiplier + total_price = discounted_total if months_multiplier > 1: - price_text = f" ({price_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)" + if discount_total > 0: + original_total = price_per_month * months_multiplier + price_text = ( + f" ({price_per_month//100}₽/мес × {months_multiplier}" + f" = {original_total//100}₽ → {total_price//100}₽)" + ) + else: + price_text = f" ({price_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)" logger.info(f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес = {total_price/100}₽") else: - price_text = f" ({total_price//100}₽)" + if discount_total > 0: + original_total = price_per_month * months_multiplier + price_text = f" ({original_total//100}₽ → {total_price//100}₽)" + else: + price_text = f" ({total_price//100}₽)" display_name = f"{icon} {name}{price_text}" else: display_name = f"{icon} {name}" - + buttons.append([ InlineKeyboardButton( text=display_name, diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 190a9470..990bc1f6 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -26,15 +26,35 @@ def _resolve_discount_percent( category: str, *, period_days: Optional[int] = None, + for_addon: bool = False, ) -> int: + effective_group = promo_group + + if user is not None and effective_group is None: + effective_group = getattr(user, "promo_group", None) + + if for_addon: + if user is not None: + try: + return user.get_addon_discount(category, period_days) + except AttributeError: + pass + + if effective_group is not None: + try: + return effective_group.get_addon_discount_percent(category, period_days) + except AttributeError: + return 0 + return 0 + if user is not None: try: return user.get_promo_discount(category, period_days) except AttributeError: pass - if promo_group is not None: - return promo_group.get_discount_percent(category, period_days) + if effective_group is not None: + return effective_group.get_discount_percent(category, period_days) return 0 @@ -863,6 +883,7 @@ class SubscriptionService: promo_group, "traffic", period_days=period_hint_days, + for_addon=True, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month @@ -886,6 +907,7 @@ class SubscriptionService: promo_group, "devices", period_days=period_hint_days, + for_addon=True, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month @@ -913,6 +935,7 @@ class SubscriptionService: promo_group, "servers", period_days=period_hint_days, + for_addon=True, ) server_discount_per_month = ( server_price_per_month * servers_discount_percent // 100 diff --git a/app/states.py b/app/states.py index f824f9a5..a1890705 100644 --- a/app/states.py +++ b/app/states.py @@ -70,6 +70,7 @@ class AdminStates(StatesGroup): creating_promo_group_device_discount = State() creating_promo_group_period_discount = State() creating_promo_group_auto_assign = State() + creating_promo_group_addon_discount = State() editing_promo_group_menu = State() editing_promo_group_name = State() @@ -78,6 +79,7 @@ class AdminStates(StatesGroup): editing_promo_group_device_discount = State() editing_promo_group_period_discount = State() editing_promo_group_auto_assign = State() + editing_promo_group_addon_discount = State() editing_squad_price = State() editing_traffic_price = State() diff --git a/locales/en.json b/locales/en.json index f217ed28..f1b5863d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -151,6 +151,9 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ON": "enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_OFF": "disabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_LINE": "Add-on discounts: {status}", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", @@ -262,10 +265,14 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDONS": "🎁 Add-on discounts", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", + "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT": "Enable discounts for add-on purchases? (yes/no)", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.", + "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Please enter 'yes' or 'no' to toggle add-on discounts.", + "ADMIN_PROMO_GROUP_EDIT_ADDONS_PROMPT": "Enable add-on discounts? Current value: {current}.", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", diff --git a/locales/ru.json b/locales/ru.json index 2524c1d4..f015aa8d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -17,6 +17,9 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ON": "включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_OFF": "отключены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_LINE": "Скидки на доп. услуги: {status}", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", @@ -128,10 +131,14 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик", "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDONS": "🎁 Скидки на доп. услуги", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", + "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT": "Включить скидки на докупку доп. услуг? (да/нет)", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.", + "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Введите «да» или «нет» для включения скидок на доп. услуги.", + "ADMIN_PROMO_GROUP_EDIT_ADDONS_PROMPT": "Включить скидки на доп. услуги? Текущее значение: {current}.", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", From 1bd55c4d80674ab65b1a03772fc045d083289d1a Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 13:24:21 +0300 Subject: [PATCH 115/146] Revert "Add localization for promo group add-on discount toggle" --- app/database/crud/promo_group.py | 9 +- app/database/crud/subscription.py | 27 +--- app/database/models.py | 20 +-- app/database/universal_migration.py | 36 ----- app/handlers/admin/promo_groups.py | 160 ---------------------- app/handlers/subscription.py | 198 ++++----------------------- app/keyboards/inline.py | 168 +++++------------------ app/services/subscription_service.py | 27 +--- app/states.py | 2 - locales/en.json | 7 - locales/ru.json | 7 - 11 files changed, 65 insertions(+), 596 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index d5d781ae..3bc093f2 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -60,7 +60,6 @@ async def create_promo_group( device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, - apply_discounts_to_addons: bool = False, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) @@ -77,7 +76,6 @@ async def create_promo_group( device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, - apply_discounts_to_addons=bool(apply_discounts_to_addons), is_default=False, ) @@ -86,15 +84,13 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽," - " скидки на доп. услуги: %s", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, normalized_period_discounts, (auto_assign_total_spent_kopeks or 0) / 100, - "включены" if promo_group.apply_discounts_to_addons else "выключены", ) return promo_group @@ -110,7 +106,6 @@ async def update_promo_group( device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, - apply_discounts_to_addons: Optional[bool] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -125,8 +120,6 @@ async def update_promo_group( group.period_discounts = normalized_period_discounts or None if auto_assign_total_spent_kopeks is not None: group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) - if apply_discounts_to_addons is not None: - group.apply_discounts_to_addons = bool(apply_discounts_to_addons) await db.commit() await db.refresh(group) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index ffe62aca..91b79375 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -504,35 +504,15 @@ def _get_discount_percent( category: str, *, period_days: Optional[int] = None, - for_addon: bool = False, ) -> int: - effective_group = promo_group - - if user is not None and effective_group is None: - effective_group = getattr(user, "promo_group", None) - - if for_addon: - if user is not None: - try: - return user.get_addon_discount(category, period_days) - except AttributeError: - pass - - if effective_group is not None: - try: - return effective_group.get_addon_discount_percent(category, period_days) - except AttributeError: - return 0 - return 0 - if user is not None: try: return user.get_promo_discount(category, period_days) except AttributeError: pass - if effective_group is not None: - return effective_group.get_discount_percent(category, period_days) + if promo_group is not None: + return promo_group.get_discount_percent(category, period_days) return 0 @@ -872,7 +852,6 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "traffic", period_days=period_hint_days, - for_addon=True, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month @@ -894,7 +873,6 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "devices", period_days=period_hint_days, - for_addon=True, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month @@ -924,7 +902,6 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "servers", period_days=period_hint_days, - for_addon=True, ) server_discount_per_month = server_price_per_month * servers_discount_percent // 100 discounted_server_per_month = server_price_per_month - server_discount_per_month diff --git a/app/database/models.py b/app/database/models.py index 5f04998c..0a3ad865 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -292,7 +292,6 @@ class PromoGroup(Base): device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) - apply_discounts_to_addons = Column(Boolean, nullable=False, default=False) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -360,15 +359,6 @@ class PromoGroup(Base): return max(0, min(100, percent)) - def get_addon_discount_percent( - self, - category: str, - period_days: Optional[int] = None, - ) -> int: - if not self.apply_discounts_to_addons: - return 0 - return self.get_discount_percent(category, period_days) - class User(Base): __tablename__ = "users" @@ -418,15 +408,7 @@ class User(Base): if not self.promo_group: return 0 return self.promo_group.get_discount_percent(category, period_days) - - def get_addon_discount(self, category: str, period_days: Optional[int] = None) -> int: - if not self.promo_group: - return 0 - try: - return self.promo_group.get_addon_discount_percent(category, period_days) - except AttributeError: - return 0 - + def add_balance(self, kopeks: int) -> None: self.balance_kopeks += kopeks diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 6959b582..b123c750 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -931,39 +931,6 @@ async def ensure_promo_groups_setup(): "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" ) - addon_discount_column_exists = await check_column_exists( - "promo_groups", "apply_discounts_to_addons" - ) - - if not addon_discount_column_exists: - if db_type == "sqlite": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons INTEGER NOT NULL DEFAULT 0" - ) - ) - elif db_type == "postgresql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT FALSE" - ) - ) - elif db_type == "mysql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons TINYINT(1) NOT NULL DEFAULT 0" - ) - ) - else: - logger.error( - f"Неподдерживаемый тип БД для promo_groups.apply_discounts_to_addons: {db_type}" - ) - return False - - logger.info( - "Добавлена колонка promo_groups.apply_discounts_to_addons" - ) - column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -2027,7 +1994,6 @@ async def check_migration_status(): "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, - "promo_groups_addon_discount_column": False, "users_auto_promo_group_assigned_column": False, "subscription_crypto_link_column": False, } @@ -2045,7 +2011,6 @@ async def check_migration_status(): status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') - status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') @@ -2083,7 +2048,6 @@ async def check_migration_status(): "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", - "promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 381ebd8d..917f673f 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -39,23 +39,6 @@ def _format_discount_line(texts, group) -> str: ) -def _format_addon_discount_status(texts, enabled: bool) -> str: - return ( - texts.t("ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ON", "включены") - if enabled - else texts.t("ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_OFF", "отключены") - ) - - -def _format_addon_discount_line(texts, group: PromoGroup) -> str: - enabled = bool(getattr(group, "apply_discounts_to_addons", False)) - status = _format_addon_discount_status(texts, enabled) - return texts.t( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_LINE", - "Скидки на доп. услуги: {status}", - ).format(status=status) - - def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: if not raw or not isinstance(raw, dict): return {} @@ -240,39 +223,6 @@ def _parse_auto_assign_threshold_input(value: str) -> int: return max(0, kopeks) -def _parse_boolean_choice(value: str) -> bool: - cleaned = (value or "").strip().lower() - - if cleaned in {"1", "да", "yes", "y", "true", "on", "+"}: - return True - if cleaned in {"0", "нет", "no", "n", "false", "off", "-"}: - return False - - raise ValueError - - -async def _prompt_for_addon_discount_toggle( - message: types.Message, - state: FSMContext, - prompt_key: str, - default_text: str, - *, - current_value: Optional[bool] = None, -): - data = await state.get_data() - texts = get_texts(data.get("language", "ru")) - prompt_text = texts.t(prompt_key, default_text) - - if current_value is not None: - status = _format_addon_discount_status(texts, current_value) - try: - prompt_text = prompt_text.format(current=status) - except KeyError: - prompt_text = f"{prompt_text} ({status})" - - await message.answer(prompt_text) - - async def _prompt_for_auto_assign_threshold( message: types.Message, state: FSMContext, @@ -307,7 +257,6 @@ def _build_edit_menu_content( lines = [ header, _format_discount_line(texts, group), - _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), ] @@ -360,15 +309,6 @@ def _build_edit_menu_content( callback_data=f"promo_group_edit_field_{group.id}_devices", ) ], - [ - types.InlineKeyboardButton( - text=texts.t( - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDONS", - "🎁 Скидки на доп. услуги", - ), - callback_data=f"promo_group_edit_field_{group.id}_addons", - ) - ], [ types.InlineKeyboardButton( text=texts.t( @@ -459,7 +399,6 @@ async def show_promo_groups_menu( group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), - _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", @@ -535,7 +474,6 @@ async def show_promo_group_details( "💳 Промогруппа: {name}", ).format(name=group.name), _format_discount_line(texts, group), - _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", @@ -769,41 +707,6 @@ async def process_create_group_auto_assign( ) return - await state.update_data(new_group_auto_assign=auto_assign_kopeks) - await state.set_state(AdminStates.creating_promo_group_addon_discount) - - await _prompt_for_addon_discount_toggle( - message, - state, - "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT", - "Включить скидки на докупку доп. услуг? (да/нет)", - ) - - -@admin_required -@error_handler -async def process_create_group_addon_discount( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - addons_enabled = _parse_boolean_choice(message.text) - except ValueError: - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT", - "Введите «да» или «нет» для включения скидок на доп. услуги.", - ) - ) - return - - auto_assign_kopeks = data.get("new_group_auto_assign") - try: group = await create_promo_group( db, @@ -813,7 +716,6 @@ async def process_create_group_addon_discount( device_discount_percent=data["new_group_devices"], period_discounts=data.get("new_group_period_discounts"), auto_assign_total_spent_kopeks=auto_assign_kopeks, - apply_discounts_to_addons=addons_enabled, ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -917,16 +819,6 @@ async def prompt_edit_promo_group_field( "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT", "Введите новую скидку на устройства (текущее значение: {current}%):", ).format(current=group.device_discount_percent) - elif field == "addons": - await state.set_state(AdminStates.editing_promo_group_addon_discount) - prompt = texts.t( - "ADMIN_PROMO_GROUP_EDIT_ADDONS_PROMPT", - "Включить скидки на доп. услуги? Текущее значение: {current}.", - ).format( - current=_format_addon_discount_status( - texts, getattr(group, "apply_discounts_to_addons", False) - ) - ) elif field == "periods": await state.set_state(AdminStates.editing_promo_group_period_discount) current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) @@ -1171,50 +1063,6 @@ async def process_edit_group_auto_assign( ) -@admin_required -@error_handler -async def process_edit_group_addon_discount( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - addons_enabled = _parse_boolean_choice(message.text) - except ValueError: - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT", - "Введите «да» или «нет» для включения скидок на доп. услуги.", - ) - ) - return - - group = await get_promo_group_by_id(db, data.get("edit_group_id")) - if not group: - await message.answer("❌ Промогруппа не найдена") - await state.clear() - return - - group = await update_promo_group( - db, - group, - apply_discounts_to_addons=addons_enabled, - ) - await state.set_state(AdminStates.editing_promo_group_menu) - - await _send_edit_menu_after_update( - message, - texts, - group, - data.get("language", db_user.language), - texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), - ) - - @admin_required @error_handler async def show_promo_group_members( @@ -1391,10 +1239,6 @@ def register_handlers(dp: Dispatcher): process_create_group_auto_assign, AdminStates.creating_promo_group_auto_assign, ) - dp.message.register( - process_create_group_addon_discount, - AdminStates.creating_promo_group_addon_discount, - ) dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name) dp.message.register( @@ -1417,7 +1261,3 @@ def register_handlers(dp: Dispatcher): process_edit_group_auto_assign, AdminStates.editing_promo_group_auto_assign, ) - dp.message.register( - process_edit_group_addon_discount, - AdminStates.editing_promo_group_addon_discount, - ) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 5681f5d0..97b91d0b 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -1142,12 +1142,11 @@ async def handle_add_countries( await callback.message.edit_text( text, reply_markup=get_manage_countries_keyboard( - countries, - current_countries.copy(), - current_countries, + countries, + current_countries.copy(), + current_countries, db_user.language, - subscription.end_date, - getattr(db_user, "promo_group", None), + subscription.end_date ), parse_mode="HTML" ) @@ -1233,11 +1232,10 @@ async def handle_manage_country( await callback.message.edit_reply_markup( reply_markup=get_manage_countries_keyboard( countries, - current_selected, - subscription.connected_squads, + current_selected, + subscription.connected_squads, db_user.language, - subscription.end_date, - getattr(db_user, "promo_group", None), + subscription.end_date ) ) logger.info(f"✅ Клавиатура обновлена") @@ -1253,11 +1251,7 @@ async def apply_countries_changes( db: AsyncSession, state: FSMContext ): - from app.utils.pricing_utils import ( - get_remaining_months, - calculate_prorated_price, - apply_percentage_discount, - ) + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price logger.info(f"🔧 Применение изменений стран") @@ -1294,50 +1288,27 @@ async def apply_countries_changes( logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") months_to_pay = get_remaining_months(subscription.end_date) - - promo_group = getattr(db_user, "promo_group", None) - addons_enabled = bool(promo_group and getattr(promo_group, "apply_discounts_to_addons", False)) - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None - servers_discount_percent = 0 - if addons_enabled: - try: - servers_discount_percent = promo_group.get_addon_discount_percent( - "servers", - period_days=period_hint_days, - ) - except AttributeError: - servers_discount_percent = 0 - - from app.utils.pricing_utils import apply_percentage_discount - + cost_per_month = 0 added_names = [] removed_names = [] - + added_server_prices = [] - + for country in countries: if country['uuid'] in added: server_price_per_month = country['price_kopeks'] - discounted_per_month, _ = apply_percentage_discount( - server_price_per_month, - servers_discount_percent, - ) - cost_per_month += discounted_per_month + cost_per_month += server_price_per_month added_names.append(country['name']) if country['uuid'] in removed: removed_names.append(country['name']) - + total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) - + for country in countries: if country['uuid'] in added: server_price_per_month = country['price_kopeks'] - discounted_per_month, _ = apply_percentage_discount( - server_price_per_month, - servers_discount_percent, - ) - server_total_price = discounted_per_month * charged_months + server_total_price = server_price_per_month * charged_months added_server_prices.append(server_total_price) logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽") @@ -1483,11 +1454,7 @@ async def handle_add_traffic( f"📈 Добавить трафик к подписке\n\n" f"Текущий лимит: {texts.format_traffic(current_traffic)}\n" f"Выберите дополнительный трафик:", - reply_markup=get_add_traffic_keyboard( - db_user.language, - subscription.end_date, - getattr(db_user, "promo_group", None), - ), + reply_markup=get_add_traffic_keyboard(db_user.language, subscription.end_date), parse_mode="HTML" ) @@ -1515,12 +1482,7 @@ async def handle_change_devices( f"💡 Важно:\n" f"• При увеличении - доплата пропорционально оставшемуся времени\n" f"• При уменьшении - возврат средств не производится", - reply_markup=get_change_devices_keyboard( - current_devices, - db_user.language, - subscription.end_date, - getattr(db_user, "promo_group", None), - ), + reply_markup=get_change_devices_keyboard(current_devices, db_user.language, subscription.end_date), parse_mode="HTML" ) @@ -1531,11 +1493,7 @@ async def confirm_change_devices( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import ( - get_remaining_months, - calculate_prorated_price, - apply_percentage_discount, - ) + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) @@ -1566,31 +1524,7 @@ async def confirm_change_devices( chargeable_devices = additional_devices devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE - - promo_group = getattr(db_user, "promo_group", None) - addons_enabled = bool(promo_group and getattr(promo_group, "apply_discounts_to_addons", False)) - period_hint_days = None - if subscription.end_date: - months_remaining = get_remaining_months(subscription.end_date) - period_hint_days = months_remaining * 30 if months_remaining > 0 else None - devices_discount_percent = 0 - if addons_enabled: - try: - devices_discount_percent = promo_group.get_addon_discount_percent( - "devices", - period_days=period_hint_days, - ) - except AttributeError: - devices_discount_percent = 0 - - discounted_per_month, _ = apply_percentage_discount( - devices_price_per_month, - devices_discount_percent, - ) - price, charged_months = calculate_prorated_price( - discounted_per_month, - subscription.end_date, - ) + price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks @@ -2205,45 +2139,9 @@ async def confirm_add_devices( return devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE - - promo_group = getattr(db_user, "promo_group", None) - addons_enabled = bool(promo_group and getattr(promo_group, "apply_discounts_to_addons", False)) - period_hint_days = None - if subscription.end_date: - months_remaining = get_remaining_months(subscription.end_date) - period_hint_days = months_remaining * 30 if months_remaining > 0 else None - devices_discount_percent = 0 - if addons_enabled: - try: - devices_discount_percent = promo_group.get_addon_discount_percent( - "devices", - period_days=period_hint_days, - ) - except AttributeError: - devices_discount_percent = 0 - - discounted_per_month, _ = apply_percentage_discount( - devices_price_per_month, - devices_discount_percent, - ) - price, charged_months = calculate_prorated_price( - discounted_per_month, - subscription.end_date, - ) - - if devices_discount_percent > 0: - logger.info( - "Добавление %s устройств: %s₽/мес → %s₽/мес × %s мес = %s₽", - devices_count, - devices_price_per_month / 100, - discounted_per_month / 100, - charged_months, - price / 100, - ) - else: - logger.info( - f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽" - ) + price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) + + logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽") if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks @@ -3604,23 +3502,11 @@ async def add_traffic( subscription = db_user.subscription price = settings.get_traffic_price(traffic_gb) - + if price == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) return - - promo_group = getattr(db_user, "promo_group", None) - addons_enabled = bool(promo_group and getattr(promo_group, "apply_discounts_to_addons", False)) - if addons_enabled: - from app.utils.pricing_utils import apply_percentage_discount - - try: - discount_percent = promo_group.get_addon_discount_percent("traffic") - except AttributeError: - discount_percent = 0 - - price, _ = apply_percentage_discount(price, discount_percent) - + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks message_text = texts.t( @@ -4039,34 +3925,11 @@ async def handle_add_country_to_subscription( selected_countries.append(country_uuid) logger.info(f"🔍 Добавлена страна: {country_uuid}") - from app.utils.pricing_utils import apply_percentage_discount - - subscription = db_user.subscription - months_multiplier = get_remaining_months(subscription.end_date) if subscription else 1 - period_hint_days = months_multiplier * 30 if months_multiplier > 0 else None - promo_group = getattr(db_user, "promo_group", None) - addons_enabled = bool(promo_group and getattr(promo_group, "apply_discounts_to_addons", False)) - servers_discount_percent = 0 - if addons_enabled: - try: - servers_discount_percent = promo_group.get_addon_discount_percent( - "servers", - period_days=period_hint_days, - ) - except AttributeError: - servers_discount_percent = 0 - total_price = 0 for country in countries: if country['uuid'] in selected_countries and country['uuid'] not in db_user.subscription.connected_squads: - price_per_month = country['price_kopeks'] - discounted_per_month, discount_per_month = apply_percentage_discount( - price_per_month, - servers_discount_percent, - ) - discounted_total = discounted_per_month * months_multiplier - total_price += discounted_total - + total_price += country['price_kopeks'] + data['countries'] = selected_countries data['total_price'] = total_price await state.set_data(data) @@ -4077,14 +3940,7 @@ async def handle_add_country_to_subscription( try: from app.keyboards.inline import get_manage_countries_keyboard await callback.message.edit_reply_markup( - reply_markup=get_manage_countries_keyboard( - countries, - selected_countries, - db_user.subscription.connected_squads, - db_user.language, - subscription.end_date if subscription else None, - promo_group, - ) + reply_markup=get_manage_countries_keyboard(countries, selected_countries, db_user.subscription.connected_squads, db_user.language) ) logger.info(f"✅ Клавиатура обновлена") except Exception as e: diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index a39401d6..adb4462f 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1,4 +1,4 @@ -from typing import List, Optional, TYPE_CHECKING +from typing import List, Optional from aiogram import types from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from datetime import datetime @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts -from app.utils.pricing_utils import format_period_description, apply_percentage_discount +from app.utils.pricing_utils import format_period_description from app.utils.subscription_utils import ( get_display_subscription_link, get_happ_cryptolink_redirect_link, @@ -17,9 +17,6 @@ import logging logger = logging.getLogger(__name__) -if TYPE_CHECKING: - from app.database.models import PromoGroup - def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ @@ -1126,30 +1123,21 @@ def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_add_traffic_keyboard( - language: str = DEFAULT_LANGUAGE, - subscription_end_date: datetime = None, - promo_group: Optional["PromoGroup"] = None, -) -> InlineKeyboardMarkup: +def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months from app.config import settings texts = get_texts(language) - + months_multiplier = 1 period_text = "" if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: period_text = f" (за {months_multiplier} мес)" - - period_hint_days = months_multiplier * 30 if months_multiplier > 0 else None - addons_enabled = bool( - promo_group and getattr(promo_group, "apply_discounts_to_addons", False) - ) - + packages = settings.get_traffic_packages() enabled_packages = [pkg for pkg in packages if pkg['enabled']] - + if not enabled_packages: return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton( @@ -1163,50 +1151,23 @@ def get_add_traffic_keyboard( ]) buttons = [] - + for package in enabled_packages: gb = package['gb'] price_per_month = package['price'] - original_total = price_per_month * months_multiplier - - discount_percent = 0 - if addons_enabled: - try: - discount_percent = promo_group.get_addon_discount_percent( - "traffic", - period_days=period_hint_days, - ) - except AttributeError: - discount_percent = 0 - - discounted_per_month, discount_per_month = apply_percentage_discount( - price_per_month, - discount_percent, - ) - total_price = discounted_per_month * months_multiplier - total_discount = discount_per_month * months_multiplier - - if total_discount > 0: - price_display = f"{original_total//100} ₽ → {total_price//100} ₽" - else: - price_display = f"{total_price//100} ₽" - - if period_text and total_discount <= 0: - price_display += period_text - elif period_text and total_discount > 0: - price_display += period_text - + total_price = price_per_month * months_multiplier + if gb == 0: if language == "ru": - text = f"♾️ Безлимитный трафик - {price_display}" + text = f"♾️ Безлимитный трафик - {total_price//100} ₽{period_text}" else: - text = f"♾️ Unlimited traffic - {price_display}" + text = f"♾️ Unlimited traffic - {total_price//100} ₽{period_text}" else: if language == "ru": - text = f"📊 +{gb} ГБ трафика - {price_display}" + text = f"📊 +{gb} ГБ трафика - {total_price//100} ₽{period_text}" else: - text = f"📊 +{gb} GB traffic - {price_display}" - + text = f"📊 +{gb} GB traffic - {total_price//100} ₽{period_text}" + buttons.append([ InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}") ]) @@ -1220,29 +1181,20 @@ def get_add_traffic_keyboard( return InlineKeyboardMarkup(inline_keyboard=buttons) -def get_change_devices_keyboard( - current_devices: int, - language: str = DEFAULT_LANGUAGE, - subscription_end_date: datetime = None, - promo_group: Optional["PromoGroup"] = None, -) -> InlineKeyboardMarkup: +def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months from app.config import settings texts = get_texts(language) - + months_multiplier = 1 period_text = "" if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: period_text = f" (за {months_multiplier} мес)" - + device_price_per_month = settings.PRICE_PER_DEVICE - period_hint_days = months_multiplier * 30 if months_multiplier > 0 else None - addons_enabled = bool( - promo_group and getattr(promo_group, "apply_discounts_to_addons", False) - ) - + buttons = [] min_devices = 1 @@ -1259,37 +1211,15 @@ def get_change_devices_keyboard( elif devices_count > current_devices: emoji = "➕" additional_devices = devices_count - current_devices - + current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT) new_chargeable = max(0, devices_count - settings.DEFAULT_DEVICE_LIMIT) chargeable_devices = new_chargeable - current_chargeable - + if chargeable_devices > 0: price_per_month = chargeable_devices * device_price_per_month - discount_percent = 0 - if addons_enabled: - try: - discount_percent = promo_group.get_addon_discount_percent( - "devices", - period_days=period_hint_days, - ) - except AttributeError: - discount_percent = 0 - - discounted_per_month, discount_per_month = apply_percentage_discount( - price_per_month, - discount_percent, - ) - total_price = discounted_per_month * months_multiplier - total_discount = discount_per_month * months_multiplier - - if total_discount > 0: - original_total = price_per_month * months_multiplier - price_display = f"+{original_total//100}₽ → {total_price//100}₽" - else: - price_display = f"+{total_price//100}₽" - - price_text = f" ({price_display}{period_text})" + total_price = price_per_month * months_multiplier + price_text = f" (+{total_price//100}₽{period_text})" action_text = "" else: price_text = " (бесплатно)" @@ -1366,8 +1296,7 @@ def get_manage_countries_keyboard( selected: List[str], current_subscription_countries: List[str], language: str = DEFAULT_LANGUAGE, - subscription_end_date: datetime = None, - promo_group: Optional["PromoGroup"] = None + subscription_end_date: datetime = None ) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months @@ -1377,37 +1306,15 @@ def get_manage_countries_keyboard( if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) logger.info(f"🔍 Расчет для управления странами: осталось {months_multiplier} месяцев до {subscription_end_date}") - - period_hint_days = months_multiplier * 30 if months_multiplier > 0 else None - addons_enabled = bool( - promo_group and getattr(promo_group, "apply_discounts_to_addons", False) - ) - + buttons = [] total_cost = 0 - + for country in countries: uuid = country['uuid'] name = country['name'] price_per_month = country['price_kopeks'] - - discount_percent = 0 - if addons_enabled: - try: - discount_percent = promo_group.get_addon_discount_percent( - "servers", - period_days=period_hint_days, - ) - except AttributeError: - discount_percent = 0 - - discounted_per_month, discount_per_month = apply_percentage_discount( - price_per_month, - discount_percent, - ) - discounted_total = discounted_per_month * months_multiplier - discount_total = discount_per_month * months_multiplier - + if uuid in current_subscription_countries: if uuid in selected: icon = "✅" @@ -1416,32 +1323,21 @@ def get_manage_countries_keyboard( else: if uuid in selected: icon = "➕" - total_cost += discounted_total + total_cost += price_per_month * months_multiplier else: icon = "⚪" - + if uuid not in current_subscription_countries and uuid in selected: - total_price = discounted_total + total_price = price_per_month * months_multiplier if months_multiplier > 1: - if discount_total > 0: - original_total = price_per_month * months_multiplier - price_text = ( - f" ({price_per_month//100}₽/мес × {months_multiplier}" - f" = {original_total//100}₽ → {total_price//100}₽)" - ) - else: - price_text = f" ({price_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)" + price_text = f" ({price_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)" logger.info(f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес = {total_price/100}₽") else: - if discount_total > 0: - original_total = price_per_month * months_multiplier - price_text = f" ({original_total//100}₽ → {total_price//100}₽)" - else: - price_text = f" ({total_price//100}₽)" + price_text = f" ({total_price//100}₽)" display_name = f"{icon} {name}{price_text}" else: display_name = f"{icon} {name}" - + buttons.append([ InlineKeyboardButton( text=display_name, diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 990bc1f6..190a9470 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -26,35 +26,15 @@ def _resolve_discount_percent( category: str, *, period_days: Optional[int] = None, - for_addon: bool = False, ) -> int: - effective_group = promo_group - - if user is not None and effective_group is None: - effective_group = getattr(user, "promo_group", None) - - if for_addon: - if user is not None: - try: - return user.get_addon_discount(category, period_days) - except AttributeError: - pass - - if effective_group is not None: - try: - return effective_group.get_addon_discount_percent(category, period_days) - except AttributeError: - return 0 - return 0 - if user is not None: try: return user.get_promo_discount(category, period_days) except AttributeError: pass - if effective_group is not None: - return effective_group.get_discount_percent(category, period_days) + if promo_group is not None: + return promo_group.get_discount_percent(category, period_days) return 0 @@ -883,7 +863,6 @@ class SubscriptionService: promo_group, "traffic", period_days=period_hint_days, - for_addon=True, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month @@ -907,7 +886,6 @@ class SubscriptionService: promo_group, "devices", period_days=period_hint_days, - for_addon=True, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month @@ -935,7 +913,6 @@ class SubscriptionService: promo_group, "servers", period_days=period_hint_days, - for_addon=True, ) server_discount_per_month = ( server_price_per_month * servers_discount_percent // 100 diff --git a/app/states.py b/app/states.py index a1890705..f824f9a5 100644 --- a/app/states.py +++ b/app/states.py @@ -70,7 +70,6 @@ class AdminStates(StatesGroup): creating_promo_group_device_discount = State() creating_promo_group_period_discount = State() creating_promo_group_auto_assign = State() - creating_promo_group_addon_discount = State() editing_promo_group_menu = State() editing_promo_group_name = State() @@ -79,7 +78,6 @@ class AdminStates(StatesGroup): editing_promo_group_device_discount = State() editing_promo_group_period_discount = State() editing_promo_group_auto_assign = State() - editing_promo_group_addon_discount = State() editing_squad_price = State() editing_traffic_price = State() diff --git a/locales/en.json b/locales/en.json index f1b5863d..f217ed28 100644 --- a/locales/en.json +++ b/locales/en.json @@ -151,9 +151,6 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ON": "enabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_OFF": "disabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_LINE": "Add-on discounts: {status}", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", @@ -265,14 +262,10 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDONS": "🎁 Add-on discounts", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", - "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT": "Enable discounts for add-on purchases? (yes/no)", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.", - "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Please enter 'yes' or 'no' to toggle add-on discounts.", - "ADMIN_PROMO_GROUP_EDIT_ADDONS_PROMPT": "Enable add-on discounts? Current value: {current}.", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", diff --git a/locales/ru.json b/locales/ru.json index f015aa8d..2524c1d4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -17,9 +17,6 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ON": "включены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_OFF": "отключены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_LINE": "Скидки на доп. услуги: {status}", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", @@ -131,14 +128,10 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик", "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDONS": "🎁 Скидки на доп. услуги", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", - "ADMIN_PROMO_GROUP_CREATE_ADDON_DISCOUNT_PROMPT": "Включить скидки на докупку доп. услуг? (да/нет)", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.", - "ADMIN_PROMO_GROUP_INVALID_ADDON_DISCOUNT": "Введите «да» или «нет» для включения скидок на доп. услуги.", - "ADMIN_PROMO_GROUP_EDIT_ADDONS_PROMPT": "Включить скидки на доп. услуги? Текущее значение: {current}.", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", From 6483efc099031579601db5789f2cadf7e5baac33 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 13:25:58 +0300 Subject: [PATCH 116/146] Add promo group add-on discount toggle --- app/database/crud/promo_group.py | 8 +- app/database/crud/subscription.py | 16 ++- app/database/models.py | 26 +++- app/database/universal_migration.py | 44 +++++++ app/handlers/admin/promo_groups.py | 170 +++++++++++++++++++++++++++ app/services/subscription_service.py | 16 ++- app/states.py | 2 + locales/en.json | 8 ++ locales/ru.json | 8 ++ 9 files changed, 290 insertions(+), 8 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 3bc093f2..c54a28b7 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -60,6 +60,7 @@ async def create_promo_group( device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + apply_addon_discounts: bool = True, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) @@ -76,6 +77,7 @@ async def create_promo_group( device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, + apply_addon_discounts=bool(apply_addon_discounts), is_default=False, ) @@ -84,12 +86,13 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s, addons=%s) и порогом автоприсвоения %s₽", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, normalized_period_discounts, + promo_group.apply_addon_discounts, (auto_assign_total_spent_kopeks or 0) / 100, ) @@ -106,6 +109,7 @@ async def update_promo_group( device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + apply_addon_discounts: Optional[bool] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -120,6 +124,8 @@ async def update_promo_group( group.period_discounts = normalized_period_discounts or None if auto_assign_total_spent_kopeks is not None: group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) + if apply_addon_discounts is not None: + group.apply_addon_discounts = bool(apply_addon_discounts) await db.commit() await db.refresh(group) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 91b79375..a6c934ba 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -504,15 +504,24 @@ def _get_discount_percent( category: str, *, period_days: Optional[int] = None, + is_addon_purchase: bool = False, ) -> int: if user is not None: try: - return user.get_promo_discount(category, period_days) + return user.get_promo_discount( + category, + period_days, + is_addon_purchase=is_addon_purchase, + ) except AttributeError: pass if promo_group is not None: - return promo_group.get_discount_percent(category, period_days) + return promo_group.get_discount_percent( + category, + period_days, + is_addon_purchase=is_addon_purchase, + ) return 0 @@ -852,6 +861,7 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "traffic", period_days=period_hint_days, + is_addon_purchase=True, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month @@ -873,6 +883,7 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "devices", period_days=period_hint_days, + is_addon_purchase=True, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month @@ -902,6 +913,7 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "servers", period_days=period_hint_days, + is_addon_purchase=True, ) server_discount_per_month = server_price_per_month * servers_discount_percent // 100 discounted_server_per_month = server_price_per_month - server_discount_per_month diff --git a/app/database/models.py b/app/database/models.py index 0a3ad865..462ca171 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -292,6 +292,7 @@ class PromoGroup(Base): device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) + apply_addon_discounts = Column(Boolean, nullable=False, default=True) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -346,7 +347,16 @@ class PromoGroup(Base): return 0 - def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int: + def get_discount_percent( + self, + category: str, + period_days: Optional[int] = None, + *, + is_addon_purchase: bool = False, + ) -> int: + if is_addon_purchase and not self.apply_addon_discounts: + return 0 + if category == "period": return max(0, min(100, self._get_period_discount(period_days))) @@ -404,10 +414,20 @@ class User(Base): parts = [self.first_name, self.last_name] return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}" - def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int: + def get_promo_discount( + self, + category: str, + period_days: Optional[int] = None, + *, + is_addon_purchase: bool = False, + ) -> int: if not self.promo_group: return 0 - return self.promo_group.get_discount_percent(category, period_days) + return self.promo_group.get_discount_percent( + category, + period_days, + is_addon_purchase=is_addon_purchase, + ) def add_balance(self, kopeks: int) -> None: self.balance_kopeks += kopeks diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index b123c750..cb8201a2 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -931,6 +931,47 @@ async def ensure_promo_groups_setup(): "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" ) + addon_discounts_column_exists = await check_column_exists( + "promo_groups", "apply_addon_discounts" + ) + + if not addon_discounts_column_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_addon_discounts BOOLEAN DEFAULT 1" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET apply_addon_discounts = 1 WHERE apply_addon_discounts IS NULL" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_addon_discounts BOOLEAN NOT NULL DEFAULT TRUE" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_addon_discounts BOOLEAN DEFAULT 1" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET apply_addon_discounts = 1 WHERE apply_addon_discounts IS NULL" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.apply_addon_discounts: {db_type}" + ) + return False + + logger.info("Добавлена колонка promo_groups.apply_addon_discounts") + column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -1994,6 +2035,7 @@ async def check_migration_status(): "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, + "promo_groups_addon_discount_column": False, "users_auto_promo_group_assigned_column": False, "subscription_crypto_link_column": False, } @@ -2011,6 +2053,7 @@ async def check_migration_status(): status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') + status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_addon_discounts') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') @@ -2048,6 +2091,7 @@ async def check_migration_status(): "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", + "promo_groups_addon_discount_column": "Колонка apply_addon_discounts у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 917f673f..6343f556 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -39,6 +39,32 @@ def _format_discount_line(texts, group) -> str: ) +def _format_addon_discount_line(texts, group) -> str: + if getattr(group, "apply_addon_discounts", True): + return texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED", + "Скидки на доп. услуги: включены", + ) + + return texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED", + "Скидки на доп. услуги: отключены", + ) + + +def _format_addon_discount_short(texts, enabled: bool) -> str: + if enabled: + return texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED_SHORT", + "включены", + ) + + return texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED_SHORT", + "отключены", + ) + + def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: if not raw or not isinstance(raw, dict): return {} @@ -161,6 +187,42 @@ async def _prompt_for_period_discounts( await message.answer(prompt_text) +def _parse_boolean_choice(value: str) -> bool: + cleaned = (value or "").strip().lower() + + truthy = {"1", "true", "yes", "y", "да", "on", "+"} + falsy = {"0", "false", "no", "n", "нет", "off", "-"} + + if cleaned in truthy: + return True + if cleaned in falsy: + return False + + raise ValueError + + +async def _prompt_for_addon_discount_choice( + message: types.Message, + state: FSMContext, + prompt_key: str, + default_text: str, + *, + current_value: Optional[bool] = None, +): + data = await state.get_data() + texts = get_texts(data.get("language", "ru")) + prompt_text = texts.t(prompt_key, default_text) + + if current_value is not None: + current_display = _format_addon_discount_short(texts, current_value) + try: + prompt_text = prompt_text.format(current=current_display) + except KeyError: + pass + + await message.answer(prompt_text) + + def _format_rubles(amount_kopeks: int) -> str: if amount_kopeks <= 0: return "0" @@ -257,6 +319,7 @@ def _build_edit_menu_content( lines = [ header, _format_discount_line(texts, group), + _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), ] @@ -309,6 +372,15 @@ def _build_edit_menu_content( callback_data=f"promo_group_edit_field_{group.id}_devices", ) ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS", + "💼 Скидки на доп. услуги", + ), + callback_data=f"promo_group_edit_field_{group.id}_addon", + ) + ], [ types.InlineKeyboardButton( text=texts.t( @@ -399,6 +471,7 @@ async def show_promo_groups_menu( group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), + _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", @@ -474,6 +547,7 @@ async def show_promo_group_details( "💳 Промогруппа: {name}", ).format(name=group.name), _format_discount_line(texts, group), + _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", @@ -675,6 +749,39 @@ async def process_create_group_period_discounts( return await state.update_data(new_group_period_discounts=period_discounts) + await state.set_state(AdminStates.creating_promo_group_addon_discount) + + await _prompt_for_addon_discount_choice( + message, + state, + "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT", + "Включить скидки на докупку доп. услуг? (да/нет)", + ) + + +@admin_required +@error_handler +async def process_create_group_addon_discount( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + apply_addon_discounts = _parse_boolean_choice(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_BOOLEAN", + "Введите «да» или «нет».", + ) + ) + return + + await state.update_data(new_group_apply_addon_discounts=apply_addon_discounts) await state.set_state(AdminStates.creating_promo_group_auto_assign) await _prompt_for_auto_assign_threshold( @@ -716,6 +823,7 @@ async def process_create_group_auto_assign( device_discount_percent=data["new_group_devices"], period_discounts=data.get("new_group_period_discounts"), auto_assign_total_spent_kopeks=auto_assign_kopeks, + apply_addon_discounts=data.get("new_group_apply_addon_discounts", True), ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -819,6 +927,16 @@ async def prompt_edit_promo_group_field( "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT", "Введите новую скидку на устройства (текущее значение: {current}%):", ).format(current=group.device_discount_percent) + elif field == "addon": + await state.set_state(AdminStates.editing_promo_group_addon_discount) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT", + "Включить скидки на докупку доп. услуг? Сейчас: {current}. (да/нет)", + ).format( + current=_format_addon_discount_short( + texts, getattr(group, "apply_addon_discounts", True) + ) + ) elif field == "periods": await state.set_state(AdminStates.editing_promo_group_period_discount) current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) @@ -979,6 +1097,50 @@ async def process_edit_group_devices( ) +@admin_required +@error_handler +async def process_edit_group_addon_discount( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + apply_addon_discounts = _parse_boolean_choice(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_BOOLEAN", + "Введите «да» или «нет».", + ) + ) + return + + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group( + db, + group, + apply_addon_discounts=apply_addon_discounts, + ) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( + message, + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), + ) + + @admin_required @error_handler async def process_edit_group_period_discounts( @@ -1235,6 +1397,10 @@ def register_handlers(dp: Dispatcher): process_create_group_period_discounts, AdminStates.creating_promo_group_period_discount, ) + dp.message.register( + process_create_group_addon_discount, + AdminStates.creating_promo_group_addon_discount, + ) dp.message.register( process_create_group_auto_assign, AdminStates.creating_promo_group_auto_assign, @@ -1253,6 +1419,10 @@ def register_handlers(dp: Dispatcher): process_edit_group_devices, AdminStates.editing_promo_group_device_discount, ) + dp.message.register( + process_edit_group_addon_discount, + AdminStates.editing_promo_group_addon_discount, + ) dp.message.register( process_edit_group_period_discounts, AdminStates.editing_promo_group_period_discount, diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 190a9470..e2ac13f6 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -26,15 +26,24 @@ def _resolve_discount_percent( category: str, *, period_days: Optional[int] = None, + is_addon_purchase: bool = False, ) -> int: if user is not None: try: - return user.get_promo_discount(category, period_days) + return user.get_promo_discount( + category, + period_days, + is_addon_purchase=is_addon_purchase, + ) except AttributeError: pass if promo_group is not None: - return promo_group.get_discount_percent(category, period_days) + return promo_group.get_discount_percent( + category, + period_days, + is_addon_purchase=is_addon_purchase, + ) return 0 @@ -863,6 +872,7 @@ class SubscriptionService: promo_group, "traffic", period_days=period_hint_days, + is_addon_purchase=True, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month @@ -886,6 +896,7 @@ class SubscriptionService: promo_group, "devices", period_days=period_hint_days, + is_addon_purchase=True, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month @@ -913,6 +924,7 @@ class SubscriptionService: promo_group, "servers", period_days=period_hint_days, + is_addon_purchase=True, ) server_discount_per_month = ( server_price_per_month * servers_discount_percent // 100 diff --git a/app/states.py b/app/states.py index f824f9a5..562c6db6 100644 --- a/app/states.py +++ b/app/states.py @@ -69,6 +69,7 @@ class AdminStates(StatesGroup): creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() creating_promo_group_period_discount = State() + creating_promo_group_addon_discount = State() creating_promo_group_auto_assign = State() editing_promo_group_menu = State() @@ -76,6 +77,7 @@ class AdminStates(StatesGroup): editing_promo_group_traffic_discount = State() editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() + editing_promo_group_addon_discount = State() editing_promo_group_period_discount = State() editing_promo_group_auto_assign = State() diff --git a/locales/en.json b/locales/en.json index f217ed28..4aea70b9 100644 --- a/locales/en.json +++ b/locales/en.json @@ -151,6 +151,10 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Add-on discounts: enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Add-on discounts: disabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED_SHORT": "enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED_SHORT": "disabled", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", @@ -244,7 +248,9 @@ "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", + "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT": "Enable add-on discounts? (yes/no)", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", + "ADMIN_PROMO_GROUP_INVALID_BOOLEAN": "Enter “yes” or “no”.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", @@ -252,6 +258,7 @@ "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100). Current value: {current}.", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100). Current value: {current}.", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100). Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT": "Enable add-on discounts? Current: {current}. (yes/no)", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled", @@ -262,6 +269,7 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS": "💼 Add-on discounts", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", diff --git a/locales/ru.json b/locales/ru.json index 2524c1d4..ccf93093 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -17,6 +17,10 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Скидки на доп. услуги: включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Скидки на доп. услуги: отключены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED_SHORT": "включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED_SHORT": "отключены", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", @@ -110,7 +114,9 @@ "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", + "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT": "Включить скидки на докупку доп. услуг? (да/нет)", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", + "ADMIN_PROMO_GROUP_INVALID_BOOLEAN": "Введите «да» или «нет».", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", @@ -118,6 +124,7 @@ "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100). Текущее значение: {current}.", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100). Текущее значение: {current}.", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100). Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT": "Включить скидки на докупку доп. услуг? Сейчас: {current}. (да/нет)", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена", @@ -128,6 +135,7 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик", "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", + "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS": "💼 Скидки на доп. услуги", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", From 0fd3452dd6cb3c0d639fd563c72e1d2c7610c673 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 13:30:28 +0300 Subject: [PATCH 117/146] Revert "Add promo group add-on discount toggle" --- app/database/crud/promo_group.py | 8 +- app/database/crud/subscription.py | 16 +-- app/database/models.py | 26 +--- app/database/universal_migration.py | 44 ------- app/handlers/admin/promo_groups.py | 170 --------------------------- app/services/subscription_service.py | 16 +-- app/states.py | 2 - locales/en.json | 8 -- locales/ru.json | 8 -- 9 files changed, 8 insertions(+), 290 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index c54a28b7..3bc093f2 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -60,7 +60,6 @@ async def create_promo_group( device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, - apply_addon_discounts: bool = True, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) @@ -77,7 +76,6 @@ async def create_promo_group( device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, - apply_addon_discounts=bool(apply_addon_discounts), is_default=False, ) @@ -86,13 +84,12 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s, addons=%s) и порогом автоприсвоения %s₽", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, normalized_period_discounts, - promo_group.apply_addon_discounts, (auto_assign_total_spent_kopeks or 0) / 100, ) @@ -109,7 +106,6 @@ async def update_promo_group( device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, - apply_addon_discounts: Optional[bool] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -124,8 +120,6 @@ async def update_promo_group( group.period_discounts = normalized_period_discounts or None if auto_assign_total_spent_kopeks is not None: group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) - if apply_addon_discounts is not None: - group.apply_addon_discounts = bool(apply_addon_discounts) await db.commit() await db.refresh(group) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index a6c934ba..91b79375 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -504,24 +504,15 @@ def _get_discount_percent( category: str, *, period_days: Optional[int] = None, - is_addon_purchase: bool = False, ) -> int: if user is not None: try: - return user.get_promo_discount( - category, - period_days, - is_addon_purchase=is_addon_purchase, - ) + return user.get_promo_discount(category, period_days) except AttributeError: pass if promo_group is not None: - return promo_group.get_discount_percent( - category, - period_days, - is_addon_purchase=is_addon_purchase, - ) + return promo_group.get_discount_percent(category, period_days) return 0 @@ -861,7 +852,6 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "traffic", period_days=period_hint_days, - is_addon_purchase=True, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month @@ -883,7 +873,6 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "devices", period_days=period_hint_days, - is_addon_purchase=True, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month @@ -913,7 +902,6 @@ async def calculate_addon_cost_for_remaining_period( promo_group, "servers", period_days=period_hint_days, - is_addon_purchase=True, ) server_discount_per_month = server_price_per_month * servers_discount_percent // 100 discounted_server_per_month = server_price_per_month - server_discount_per_month diff --git a/app/database/models.py b/app/database/models.py index 462ca171..0a3ad865 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -292,7 +292,6 @@ class PromoGroup(Base): device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) - apply_addon_discounts = Column(Boolean, nullable=False, default=True) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -347,16 +346,7 @@ class PromoGroup(Base): return 0 - def get_discount_percent( - self, - category: str, - period_days: Optional[int] = None, - *, - is_addon_purchase: bool = False, - ) -> int: - if is_addon_purchase and not self.apply_addon_discounts: - return 0 - + def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int: if category == "period": return max(0, min(100, self._get_period_discount(period_days))) @@ -414,20 +404,10 @@ class User(Base): parts = [self.first_name, self.last_name] return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}" - def get_promo_discount( - self, - category: str, - period_days: Optional[int] = None, - *, - is_addon_purchase: bool = False, - ) -> int: + def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int: if not self.promo_group: return 0 - return self.promo_group.get_discount_percent( - category, - period_days, - is_addon_purchase=is_addon_purchase, - ) + return self.promo_group.get_discount_percent(category, period_days) def add_balance(self, kopeks: int) -> None: self.balance_kopeks += kopeks diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index cb8201a2..b123c750 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -931,47 +931,6 @@ async def ensure_promo_groups_setup(): "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" ) - addon_discounts_column_exists = await check_column_exists( - "promo_groups", "apply_addon_discounts" - ) - - if not addon_discounts_column_exists: - if db_type == "sqlite": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN apply_addon_discounts BOOLEAN DEFAULT 1" - ) - ) - await conn.execute( - text( - "UPDATE promo_groups SET apply_addon_discounts = 1 WHERE apply_addon_discounts IS NULL" - ) - ) - elif db_type == "postgresql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN apply_addon_discounts BOOLEAN NOT NULL DEFAULT TRUE" - ) - ) - elif db_type == "mysql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN apply_addon_discounts BOOLEAN DEFAULT 1" - ) - ) - await conn.execute( - text( - "UPDATE promo_groups SET apply_addon_discounts = 1 WHERE apply_addon_discounts IS NULL" - ) - ) - else: - logger.error( - f"Неподдерживаемый тип БД для promo_groups.apply_addon_discounts: {db_type}" - ) - return False - - logger.info("Добавлена колонка promo_groups.apply_addon_discounts") - column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -2035,7 +1994,6 @@ async def check_migration_status(): "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, - "promo_groups_addon_discount_column": False, "users_auto_promo_group_assigned_column": False, "subscription_crypto_link_column": False, } @@ -2053,7 +2011,6 @@ async def check_migration_status(): status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') - status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_addon_discounts') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') @@ -2091,7 +2048,6 @@ async def check_migration_status(): "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", - "promo_groups_addon_discount_column": "Колонка apply_addon_discounts у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 6343f556..917f673f 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -39,32 +39,6 @@ def _format_discount_line(texts, group) -> str: ) -def _format_addon_discount_line(texts, group) -> str: - if getattr(group, "apply_addon_discounts", True): - return texts.t( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED", - "Скидки на доп. услуги: включены", - ) - - return texts.t( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED", - "Скидки на доп. услуги: отключены", - ) - - -def _format_addon_discount_short(texts, enabled: bool) -> str: - if enabled: - return texts.t( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED_SHORT", - "включены", - ) - - return texts.t( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED_SHORT", - "отключены", - ) - - def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: if not raw or not isinstance(raw, dict): return {} @@ -187,42 +161,6 @@ async def _prompt_for_period_discounts( await message.answer(prompt_text) -def _parse_boolean_choice(value: str) -> bool: - cleaned = (value or "").strip().lower() - - truthy = {"1", "true", "yes", "y", "да", "on", "+"} - falsy = {"0", "false", "no", "n", "нет", "off", "-"} - - if cleaned in truthy: - return True - if cleaned in falsy: - return False - - raise ValueError - - -async def _prompt_for_addon_discount_choice( - message: types.Message, - state: FSMContext, - prompt_key: str, - default_text: str, - *, - current_value: Optional[bool] = None, -): - data = await state.get_data() - texts = get_texts(data.get("language", "ru")) - prompt_text = texts.t(prompt_key, default_text) - - if current_value is not None: - current_display = _format_addon_discount_short(texts, current_value) - try: - prompt_text = prompt_text.format(current=current_display) - except KeyError: - pass - - await message.answer(prompt_text) - - def _format_rubles(amount_kopeks: int) -> str: if amount_kopeks <= 0: return "0" @@ -319,7 +257,6 @@ def _build_edit_menu_content( lines = [ header, _format_discount_line(texts, group), - _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), ] @@ -372,15 +309,6 @@ def _build_edit_menu_content( callback_data=f"promo_group_edit_field_{group.id}_devices", ) ], - [ - types.InlineKeyboardButton( - text=texts.t( - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS", - "💼 Скидки на доп. услуги", - ), - callback_data=f"promo_group_edit_field_{group.id}_addon", - ) - ], [ types.InlineKeyboardButton( text=texts.t( @@ -471,7 +399,6 @@ async def show_promo_groups_menu( group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), - _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", @@ -547,7 +474,6 @@ async def show_promo_group_details( "💳 Промогруппа: {name}", ).format(name=group.name), _format_discount_line(texts, group), - _format_addon_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", @@ -749,39 +675,6 @@ async def process_create_group_period_discounts( return await state.update_data(new_group_period_discounts=period_discounts) - await state.set_state(AdminStates.creating_promo_group_addon_discount) - - await _prompt_for_addon_discount_choice( - message, - state, - "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT", - "Включить скидки на докупку доп. услуг? (да/нет)", - ) - - -@admin_required -@error_handler -async def process_create_group_addon_discount( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - apply_addon_discounts = _parse_boolean_choice(message.text) - except ValueError: - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_INVALID_BOOLEAN", - "Введите «да» или «нет».", - ) - ) - return - - await state.update_data(new_group_apply_addon_discounts=apply_addon_discounts) await state.set_state(AdminStates.creating_promo_group_auto_assign) await _prompt_for_auto_assign_threshold( @@ -823,7 +716,6 @@ async def process_create_group_auto_assign( device_discount_percent=data["new_group_devices"], period_discounts=data.get("new_group_period_discounts"), auto_assign_total_spent_kopeks=auto_assign_kopeks, - apply_addon_discounts=data.get("new_group_apply_addon_discounts", True), ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -927,16 +819,6 @@ async def prompt_edit_promo_group_field( "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT", "Введите новую скидку на устройства (текущее значение: {current}%):", ).format(current=group.device_discount_percent) - elif field == "addon": - await state.set_state(AdminStates.editing_promo_group_addon_discount) - prompt = texts.t( - "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT", - "Включить скидки на докупку доп. услуг? Сейчас: {current}. (да/нет)", - ).format( - current=_format_addon_discount_short( - texts, getattr(group, "apply_addon_discounts", True) - ) - ) elif field == "periods": await state.set_state(AdminStates.editing_promo_group_period_discount) current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) @@ -1097,50 +979,6 @@ async def process_edit_group_devices( ) -@admin_required -@error_handler -async def process_edit_group_addon_discount( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - apply_addon_discounts = _parse_boolean_choice(message.text) - except ValueError: - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_INVALID_BOOLEAN", - "Введите «да» или «нет».", - ) - ) - return - - group = await get_promo_group_by_id(db, data.get("edit_group_id")) - if not group: - await message.answer("❌ Промогруппа не найдена") - await state.clear() - return - - group = await update_promo_group( - db, - group, - apply_addon_discounts=apply_addon_discounts, - ) - await state.set_state(AdminStates.editing_promo_group_menu) - - await _send_edit_menu_after_update( - message, - texts, - group, - data.get("language", db_user.language), - texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), - ) - - @admin_required @error_handler async def process_edit_group_period_discounts( @@ -1397,10 +1235,6 @@ def register_handlers(dp: Dispatcher): process_create_group_period_discounts, AdminStates.creating_promo_group_period_discount, ) - dp.message.register( - process_create_group_addon_discount, - AdminStates.creating_promo_group_addon_discount, - ) dp.message.register( process_create_group_auto_assign, AdminStates.creating_promo_group_auto_assign, @@ -1419,10 +1253,6 @@ def register_handlers(dp: Dispatcher): process_edit_group_devices, AdminStates.editing_promo_group_device_discount, ) - dp.message.register( - process_edit_group_addon_discount, - AdminStates.editing_promo_group_addon_discount, - ) dp.message.register( process_edit_group_period_discounts, AdminStates.editing_promo_group_period_discount, diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index e2ac13f6..190a9470 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -26,24 +26,15 @@ def _resolve_discount_percent( category: str, *, period_days: Optional[int] = None, - is_addon_purchase: bool = False, ) -> int: if user is not None: try: - return user.get_promo_discount( - category, - period_days, - is_addon_purchase=is_addon_purchase, - ) + return user.get_promo_discount(category, period_days) except AttributeError: pass if promo_group is not None: - return promo_group.get_discount_percent( - category, - period_days, - is_addon_purchase=is_addon_purchase, - ) + return promo_group.get_discount_percent(category, period_days) return 0 @@ -872,7 +863,6 @@ class SubscriptionService: promo_group, "traffic", period_days=period_hint_days, - is_addon_purchase=True, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month @@ -896,7 +886,6 @@ class SubscriptionService: promo_group, "devices", period_days=period_hint_days, - is_addon_purchase=True, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month @@ -924,7 +913,6 @@ class SubscriptionService: promo_group, "servers", period_days=period_hint_days, - is_addon_purchase=True, ) server_discount_per_month = ( server_price_per_month * servers_discount_percent // 100 diff --git a/app/states.py b/app/states.py index 562c6db6..f824f9a5 100644 --- a/app/states.py +++ b/app/states.py @@ -69,7 +69,6 @@ class AdminStates(StatesGroup): creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() creating_promo_group_period_discount = State() - creating_promo_group_addon_discount = State() creating_promo_group_auto_assign = State() editing_promo_group_menu = State() @@ -77,7 +76,6 @@ class AdminStates(StatesGroup): editing_promo_group_traffic_discount = State() editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() - editing_promo_group_addon_discount = State() editing_promo_group_period_discount = State() editing_promo_group_auto_assign = State() diff --git a/locales/en.json b/locales/en.json index 4aea70b9..f217ed28 100644 --- a/locales/en.json +++ b/locales/en.json @@ -151,10 +151,6 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Add-on discounts: enabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Add-on discounts: disabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED_SHORT": "enabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED_SHORT": "disabled", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", @@ -248,9 +244,7 @@ "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", - "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT": "Enable add-on discounts? (yes/no)", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", - "ADMIN_PROMO_GROUP_INVALID_BOOLEAN": "Enter “yes” or “no”.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", @@ -258,7 +252,6 @@ "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100). Current value: {current}.", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100). Current value: {current}.", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100). Current value: {current}.", - "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT": "Enable add-on discounts? Current: {current}. (yes/no)", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled", @@ -269,7 +262,6 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS": "💼 Add-on discounts", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", diff --git a/locales/ru.json b/locales/ru.json index ccf93093..2524c1d4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -17,10 +17,6 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED": "Скидки на доп. услуги: включены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED": "Скидки на доп. услуги: отключены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_ENABLED_SHORT": "включены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_DISABLED_SHORT": "отключены", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", @@ -114,9 +110,7 @@ "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", - "ADMIN_PROMO_GROUP_CREATE_ADDON_PROMPT": "Включить скидки на докупку доп. услуг? (да/нет)", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", - "ADMIN_PROMO_GROUP_INVALID_BOOLEAN": "Введите «да» или «нет».", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", @@ -124,7 +118,6 @@ "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100). Текущее значение: {current}.", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100). Текущее значение: {current}.", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100). Текущее значение: {current}.", - "ADMIN_PROMO_GROUP_EDIT_ADDON_PROMPT": "Включить скидки на докупку доп. услуг? Сейчас: {current}. (да/нет)", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена", @@ -135,7 +128,6 @@ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик", "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", - "ADMIN_PROMO_GROUP_EDIT_FIELD_ADDON_DISCOUNTS": "💼 Скидки на доп. услуги", "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", From 497775c38239b883f6b5d7104824bc4f8daedabf Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 13:30:52 +0300 Subject: [PATCH 118/146] Revert "Revert "Add addon discount toggle for promo groups"" --- app/database/crud/promo_group.py | 8 ++- app/database/models.py | 1 + app/database/universal_migration.py | 51 +++++++++++++++++++ app/handlers/admin/promo_groups.py | 76 ++++++++++++++++++++++++++++ app/localization/locales/en.json | 6 +++ app/localization/locales/ru.json | 6 +++ app/services/subscription_service.py | 26 ++++++++-- locales/en.json | 6 +++ locales/ru.json | 6 +++ 9 files changed, 182 insertions(+), 4 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 3bc093f2..9296dd48 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -60,6 +60,7 @@ async def create_promo_group( device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + apply_discounts_to_addons: bool = True, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) @@ -76,6 +77,7 @@ async def create_promo_group( device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, + apply_discounts_to_addons=bool(apply_discounts_to_addons), is_default=False, ) @@ -84,13 +86,14 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, normalized_period_discounts, (auto_assign_total_spent_kopeks or 0) / 100, + "on" if promo_group.apply_discounts_to_addons else "off", ) return promo_group @@ -106,6 +109,7 @@ async def update_promo_group( device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + apply_discounts_to_addons: Optional[bool] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -120,6 +124,8 @@ async def update_promo_group( group.period_discounts = normalized_period_discounts or None if auto_assign_total_spent_kopeks is not None: group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) + if apply_discounts_to_addons is not None: + group.apply_discounts_to_addons = bool(apply_discounts_to_addons) await db.commit() await db.refresh(group) diff --git a/app/database/models.py b/app/database/models.py index 0a3ad865..278178ff 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -292,6 +292,7 @@ class PromoGroup(Base): device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) + apply_discounts_to_addons = Column(Boolean, nullable=False, default=True) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index b123c750..8f5648c5 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -931,6 +931,54 @@ async def ensure_promo_groups_setup(): "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" ) + addon_discount_column_exists = await check_column_exists( + "promo_groups", "apply_discounts_to_addons" + ) + + if not addon_discount_column_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT 1" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT TRUE" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET apply_discounts_to_addons = TRUE WHERE apply_discounts_to_addons IS NULL" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons TINYINT(1) NOT NULL DEFAULT 1" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.apply_discounts_to_addons: {db_type}" + ) + return False + + logger.info( + "Добавлена колонка promo_groups.apply_discounts_to_addons" + ) + column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -1994,6 +2042,7 @@ async def check_migration_status(): "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, + "promo_groups_addon_discount_column": False, "users_auto_promo_group_assigned_column": False, "subscription_crypto_link_column": False, } @@ -2011,6 +2060,7 @@ async def check_migration_status(): status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') + status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') @@ -2048,6 +2098,7 @@ async def check_migration_status(): "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", + "promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 917f673f..d21b9249 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -39,6 +39,32 @@ def _format_discount_line(texts, group) -> str: ) +def _format_addon_discounts_line(texts, group: PromoGroup) -> str: + enabled = getattr(group, "apply_discounts_to_addons", True) + if enabled: + return texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED", + "Скидки на доп. услуги: включены", + ) + return texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED", + "Скидки на доп. услуги: отключены", + ) + + +def _get_addon_discounts_button_text(texts, group: PromoGroup) -> str: + enabled = getattr(group, "apply_discounts_to_addons", True) + if enabled: + return texts.t( + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE", + "🧩 Отключить скидки на доп. услуги", + ) + return texts.t( + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE", + "🧩 Включить скидки на доп. услуги", + ) + + def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: if not raw or not isinstance(raw, dict): return {} @@ -257,6 +283,7 @@ def _build_edit_menu_content( lines = [ header, _format_discount_line(texts, group), + _format_addon_discounts_line(texts, group), _format_auto_assign_line(texts, group), ] @@ -318,6 +345,12 @@ def _build_edit_menu_content( callback_data=f"promo_group_edit_field_{group.id}_periods", ) ], + [ + types.InlineKeyboardButton( + text=_get_addon_discounts_button_text(texts, group), + callback_data=f"promo_group_toggle_addons_{group.id}", + ) + ], [ types.InlineKeyboardButton( text=texts.t( @@ -1192,6 +1225,45 @@ async def delete_promo_group_confirmed( await callback.answer() +@admin_required +@error_handler +async def toggle_promo_group_addon_discounts( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + group = await _get_group_or_alert(callback, db) + if not group: + return + + texts = get_texts(db_user.language) + + new_value = not getattr(group, "apply_discounts_to_addons", True) + + group = await update_promo_group( + db, + group, + apply_discounts_to_addons=new_value, + ) + + status_text = texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED" + if new_value + else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED", + "Скидки на докупку доп. услуг {status}.", + ).format(status="включены" if new_value else "отключены") + + await _send_edit_menu_after_update( + callback.message, + texts, + group, + db_user.language, + status_text, + ) + + await callback.answer() + + def register_handlers(dp: Dispatcher): dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups") dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_")) @@ -1200,6 +1272,10 @@ def register_handlers(dp: Dispatcher): prompt_edit_promo_group_field, F.data.startswith("promo_group_edit_field_"), ) + dp.callback_query.register( + toggle_promo_group_addon_discounts, + F.data.startswith("promo_group_toggle_addons_"), + ) dp.callback_query.register( start_edit_promo_group, F.data.regexp(r"^promo_group_edit_\d+$"), diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 98b41a5b..e2aa4e2a 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -138,6 +138,12 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Add-on purchase discounts have been disabled.", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 831a55d1..669987a3 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -15,6 +15,12 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Скидки на докупку доп. услуг отключены.", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 190a9470..54ba6720 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -38,6 +38,26 @@ def _resolve_discount_percent( return 0 + +def _resolve_addon_discount_percent( + user: Optional[User], + promo_group: Optional[PromoGroup], + category: str, + *, + period_days: Optional[int] = None, +) -> int: + group = promo_group or (getattr(user, "promo_group", None) if user else None) + + if group is not None and not getattr(group, "apply_discounts_to_addons", True): + return 0 + + return _resolve_discount_percent( + user, + promo_group, + category, + period_days=period_days, + ) + def get_traffic_reset_strategy(): from app.config import settings strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper() @@ -858,7 +878,7 @@ class SubscriptionService: if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_discount_percent = _resolve_discount_percent( + traffic_discount_percent = _resolve_addon_discount_percent( user, promo_group, "traffic", @@ -881,7 +901,7 @@ class SubscriptionService: if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _resolve_discount_percent( + devices_discount_percent = _resolve_addon_discount_percent( user, promo_group, "devices", @@ -908,7 +928,7 @@ class SubscriptionService: server = await get_server_squad_by_id(db, server_id) if server and server.is_available: server_price_per_month = server.price_kopeks - servers_discount_percent = _resolve_discount_percent( + servers_discount_percent = _resolve_addon_discount_percent( user, promo_group, "servers", diff --git a/locales/en.json b/locales/en.json index f217ed28..110c9ae9 100644 --- a/locales/en.json +++ b/locales/en.json @@ -151,6 +151,12 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Add-on purchase discounts have been disabled.", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", diff --git a/locales/ru.json b/locales/ru.json index 2524c1d4..51eab11b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -17,6 +17,12 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Скидки на докупку доп. услуг отключены.", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", From ac83a65d3b249b9e7067ceeb3fcf5cccdb971f8f Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 13:49:02 +0300 Subject: [PATCH 119/146] Fix addon discount calculations and availability checks --- app/database/crud/subscription.py | 42 ++++- app/handlers/subscription.py | 241 +++++++++++++++++++++++---- app/services/subscription_service.py | 10 +- app/utils/pricing_utils.py | 32 +++- 4 files changed, 276 insertions(+), 49 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 91b79375..cd8b5378 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1,7 +1,7 @@ import logging from datetime import datetime, timedelta from typing import Optional, List, Tuple -from sqlalchemy import select, and_, func +from sqlalchemy import select, and_, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -13,7 +13,11 @@ from app.database.models import ( PromoGroup, ) from app.database.crud.notification import clear_notifications -from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months +from app.utils.pricing_utils import ( + calculate_months_from_days, + get_remaining_months, + resolve_addon_discount_percent, +) from app.config import settings logger = logging.getLogger(__name__) @@ -517,6 +521,23 @@ def _get_discount_percent( return 0 +def _get_addon_discount_percent( + user: Optional[User], + promo_group: Optional[PromoGroup], + category: str, + *, + period_days: Optional[int] = None, +) -> int: + group = promo_group or (getattr(user, "promo_group", None) if user else None) + + return resolve_addon_discount_percent( + user, + group, + category, + period_days=period_days, + ) + + async def calculate_subscription_total_cost( db: AsyncSession, period_days: int, @@ -836,7 +857,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_server_ids is None: additional_server_ids = [] - months_to_pay = get_remaining_months(subscription.end_date) + months_to_pay = max(1, get_remaining_months(subscription.end_date)) period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None total_cost = 0 @@ -847,7 +868,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_discount_percent = _get_discount_percent( + traffic_discount_percent = _get_addon_discount_percent( user, promo_group, "traffic", @@ -868,7 +889,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _get_discount_percent( + devices_discount_percent = _get_addon_discount_percent( user, promo_group, "devices", @@ -892,12 +913,19 @@ async def calculate_addon_cost_for_remaining_period( for server_id in additional_server_ids: result = await db.execute( select(ServerSquad.price_kopeks, ServerSquad.display_name) - .where(ServerSquad.id == server_id) + .where( + ServerSquad.id == server_id, + ServerSquad.is_available.is_(True), + or_( + ServerSquad.max_users.is_(None), + ServerSquad.current_users < ServerSquad.max_users, + ), + ) ) server_data = result.first() if server_data: server_price_per_month, server_name = server_data - servers_discount_percent = _get_discount_percent( + servers_discount_percent = _get_addon_discount_percent( user, promo_group, "servers", diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 97b91d0b..d08f0b48 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -93,6 +93,51 @@ def _apply_discount_to_monthly_component( } +def _get_addon_discount_percent_for_user( + user: User, + category: str, + period_days: Optional[int] = None, +) -> int: + promo_group = getattr(user, "promo_group", None) + + if promo_group is not None and not getattr(promo_group, "apply_discounts_to_addons", True): + return 0 + + try: + return user.get_promo_discount(category, period_days) + except AttributeError: + return 0 + + +def _calculate_discounted_addon_price( + subscription: Subscription, + user: User, + base_price_per_month: int, + category: str, +) -> Dict[str, int]: + months_to_pay = max(1, get_remaining_months(subscription.end_date)) + period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + discount_percent = _get_addon_discount_percent_for_user( + user, + category, + period_hint_days, + ) + + discount_per_month = base_price_per_month * discount_percent // 100 + discounted_per_month = base_price_per_month - discount_per_month + total_price = discounted_per_month * months_to_pay + total_discount = discount_per_month * months_to_pay + + return { + "total_price": total_price, + "charged_months": months_to_pay, + "discount_percent": discount_percent, + "discount_total": total_discount, + "discount_per_month": discount_per_month, + "discounted_per_month": discounted_per_month, + } + + async def _prepare_subscription_summary( db_user: User, data: Dict[str, Any], @@ -1287,35 +1332,64 @@ async def apply_countries_changes( logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") - months_to_pay = get_remaining_months(subscription.end_date) - + months_to_pay = max(1, get_remaining_months(subscription.end_date)) + period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + servers_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "servers", + period_hint_days, + ) + cost_per_month = 0 added_names = [] removed_names = [] - + added_server_prices = [] - + total_cost = 0 + total_discount = 0 + for country in countries: if country['uuid'] in added: server_price_per_month = country['price_kopeks'] cost_per_month += server_price_per_month added_names.append(country['name']) + server_discount_per_month = ( + server_price_per_month * servers_discount_percent // 100 + ) + discounted_per_month = server_price_per_month - server_discount_per_month + server_total_price = discounted_per_month * months_to_pay + added_server_prices.append(server_total_price) + total_cost += server_total_price + total_discount += server_discount_per_month * months_to_pay if country['uuid'] in removed: removed_names.append(country['name']) - - total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) - - for country in countries: - if country['uuid'] in added: - server_price_per_month = country['price_kopeks'] - server_total_price = server_price_per_month * charged_months - added_server_prices.append(server_total_price) - - logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽") - + + charged_months = months_to_pay + + if added and servers_discount_percent > 0: + logger.info( + "Стоимость новых серверов: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽)", + cost_per_month / 100, + charged_months, + total_cost / 100, + servers_discount_percent, + total_discount / 100, + ) + else: + logger.info( + "Стоимость новых серверов: %s₽/мес × %s мес = %s₽", + cost_per_month / 100, + charged_months, + total_cost / 100, + ) + if total_cost > 0 and db_user.balance_kopeks < total_cost: missing_kopeks = total_cost - db_user.balance_kopeks required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)" + if total_discount > 0: + required_text += ( + f"\n💸 Скидка {servers_discount_percent}%: -{texts.format_price(total_discount)}" + ) message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -1398,6 +1472,10 @@ async def apply_countries_changes( success_text += "\n".join(f"• {name}" for name in added_names) if total_cost > 0: success_text += f"\n💰 Списано: {texts.format_price(total_cost)} (за {charged_months} мес)" + if total_discount > 0: + success_text += ( + f"\n💸 Скидка {servers_discount_percent}%: -{texts.format_price(total_discount)}" + ) success_text += "\n" if removed_names: @@ -1493,8 +1571,6 @@ async def confirm_change_devices( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price - new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -1522,13 +1598,26 @@ async def confirm_change_devices( chargeable_devices = max(0, additional_devices - free_devices) else: chargeable_devices = additional_devices - + devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE - price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) - + pricing = _calculate_discounted_addon_price( + subscription, + db_user, + devices_price_per_month, + "devices", + ) + price = pricing["total_price"] + charged_months = pricing["charged_months"] + discount_percent = pricing["discount_percent"] + discount_total = pricing["discount_total"] + if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" + if discount_total > 0: + required_text += ( + f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" + ) message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -1554,9 +1643,13 @@ async def confirm_change_devices( ) await callback.answer() return - + action_text = f"увеличить до {new_devices_count}" cost_text = f"Доплата: {texts.format_price(price)} (за {charged_months} мес)" if price > 0 else "Бесплатно" + if price > 0 and discount_total > 0: + cost_text += ( + f" (скидка {discount_percent}%: -{texts.format_price(discount_total)})" + ) else: price = 0 @@ -1605,7 +1698,7 @@ async def execute_change_devices( await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - charged_months = get_remaining_months(subscription.end_date) + charged_months = max(1, get_remaining_months(subscription.end_date)) await create_transaction( db=db, user_id=db_user.id, @@ -2120,8 +2213,6 @@ async def confirm_add_devices( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price - devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -2139,13 +2230,43 @@ async def confirm_add_devices( return devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE - price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) - - logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽") + pricing = _calculate_discounted_addon_price( + subscription, + db_user, + devices_price_per_month, + "devices", + ) + price = pricing["total_price"] + charged_months = pricing["charged_months"] + discount_percent = pricing["discount_percent"] + discount_total = pricing["discount_total"] + + if discount_percent > 0: + logger.info( + "Добавление %s устройств: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽)", + devices_count, + devices_price_per_month / 100, + charged_months, + price / 100, + discount_percent, + discount_total / 100, + ) + else: + logger.info( + "Добавление %s устройств: %s₽/мес × %s мес = %s₽", + devices_count, + devices_price_per_month / 100, + charged_months, + price / 100, + ) if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" + if discount_total > 0: + required_text += ( + f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" + ) message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -2204,7 +2325,12 @@ async def confirm_add_devices( f"✅ Устройства успешно добавлены!\n\n" f"📱 Добавлено: {devices_count} устройств\n" f"Новый лимит: {subscription.device_limit} устройств\n" - f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)", + f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)" + + ( + f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" + if discount_total > 0 + else "" + ), reply_markup=get_back_keyboard(db_user.language) ) @@ -3501,12 +3627,42 @@ async def add_traffic( texts = get_texts(db_user.language) subscription = db_user.subscription - price = settings.get_traffic_price(traffic_gb) - - if price == 0 and traffic_gb != 0: + price_per_month = settings.get_traffic_price(traffic_gb) + + if price_per_month == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) return - + + pricing = _calculate_discounted_addon_price( + subscription, + db_user, + price_per_month, + "traffic", + ) + price = pricing["total_price"] + charged_months = pricing["charged_months"] + discount_percent = pricing["discount_percent"] + discount_total = pricing["discount_total"] + + if discount_percent > 0: + logger.info( + "Добавление трафика +%s ГБ: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽)", + traffic_gb, + price_per_month / 100, + charged_months, + price / 100, + discount_percent, + discount_total / 100, + ) + else: + logger.info( + "Добавление трафика +%s ГБ: %s₽/мес × %s мес = %s₽", + traffic_gb, + price_per_month / 100, + charged_months, + price / 100, + ) + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks message_text = texts.t( @@ -3519,7 +3675,14 @@ async def add_traffic( "Выберите способ пополнения. Сумма подставится автоматически." ), ).format( - required=texts.format_price(price), + required=( + f"{texts.format_price(price)} (за {charged_months} мес)" + + ( + f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" + if discount_total > 0 + else "" + ) + ), balance=texts.format_price(db_user.balance_kopeks), missing=texts.format_price(missing_kopeks), ) @@ -3538,7 +3701,7 @@ async def add_traffic( try: success = await subtract_user_balance( db, db_user, price, - f"Добавление {traffic_gb} ГБ трафика" + f"Добавление {traffic_gb} ГБ трафика на {charged_months} мес" ) if not success: @@ -3558,7 +3721,9 @@ async def add_traffic( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price, - description=f"Добавление {traffic_gb} ГБ трафика" + description=( + f"Добавление {traffic_gb} ГБ трафика на {charged_months} мес" + ) ) @@ -3571,6 +3736,12 @@ async def add_traffic( else: success_text += f"📈 Добавлено: {traffic_gb} ГБ\n" success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}" + if price > 0: + success_text += f"\n💰 Списано: {texts.format_price(price)} (за {charged_months} мес)" + if discount_total > 0: + success_text += ( + f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" + ) await callback.message.edit_text( success_text, diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 54ba6720..4749b11a 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -14,7 +14,8 @@ from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, calculate_prorated_price, - validate_pricing_calculation + validate_pricing_calculation, + resolve_addon_discount_percent, ) logger = logging.getLogger(__name__) @@ -48,12 +49,9 @@ def _resolve_addon_discount_percent( ) -> int: group = promo_group or (getattr(user, "promo_group", None) if user else None) - if group is not None and not getattr(group, "apply_discounts_to_addons", True): - return 0 - - return _resolve_discount_percent( + return resolve_addon_discount_percent( user, - promo_group, + group, category, period_days=period_days, ) diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index 40d7f589..ead9ddd4 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,10 +1,14 @@ from datetime import datetime, timedelta -from typing import Tuple +from typing import Tuple, Optional, TYPE_CHECKING import logging logger = logging.getLogger(__name__) +if TYPE_CHECKING: # pragma: no cover + from app.database.models import User, PromoGroup + + def calculate_months_from_days(days: int) -> int: return max(1, round(days / 30)) @@ -61,6 +65,32 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]: return discounted_amount, discount_value +def resolve_addon_discount_percent( + user: Optional["User"], + promo_group: Optional["PromoGroup"], + category: str, + *, + period_days: Optional[int] = None, +) -> int: + """Return discount percent for add-on purchases respecting promo-group rules.""" + + group = promo_group or (getattr(user, "promo_group", None) if user else None) + + if group is not None and not getattr(group, "apply_discounts_to_addons", True): + return 0 + + if user is not None: + try: + return user.get_promo_discount(category, period_days) + except AttributeError: + pass + + if promo_group is not None: + return promo_group.get_discount_percent(category, period_days) + + return 0 + + def format_period_description(days: int, language: str = "ru") -> str: months = calculate_months_from_days(days) From bb5dd7176b502d633f4d43c417b6bbfa560f20e3 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 13:49:12 +0300 Subject: [PATCH 120/146] Fix addon discount calculations and availability checks --- app/database/crud/subscription.py | 42 ++++- app/handlers/subscription.py | 241 +++++++++++++++++++++++---- app/services/subscription_service.py | 10 +- app/utils/pricing_utils.py | 32 +++- 4 files changed, 276 insertions(+), 49 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 91b79375..cd8b5378 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1,7 +1,7 @@ import logging from datetime import datetime, timedelta from typing import Optional, List, Tuple -from sqlalchemy import select, and_, func +from sqlalchemy import select, and_, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -13,7 +13,11 @@ from app.database.models import ( PromoGroup, ) from app.database.crud.notification import clear_notifications -from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months +from app.utils.pricing_utils import ( + calculate_months_from_days, + get_remaining_months, + resolve_addon_discount_percent, +) from app.config import settings logger = logging.getLogger(__name__) @@ -517,6 +521,23 @@ def _get_discount_percent( return 0 +def _get_addon_discount_percent( + user: Optional[User], + promo_group: Optional[PromoGroup], + category: str, + *, + period_days: Optional[int] = None, +) -> int: + group = promo_group or (getattr(user, "promo_group", None) if user else None) + + return resolve_addon_discount_percent( + user, + group, + category, + period_days=period_days, + ) + + async def calculate_subscription_total_cost( db: AsyncSession, period_days: int, @@ -836,7 +857,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_server_ids is None: additional_server_ids = [] - months_to_pay = get_remaining_months(subscription.end_date) + months_to_pay = max(1, get_remaining_months(subscription.end_date)) period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None total_cost = 0 @@ -847,7 +868,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_discount_percent = _get_discount_percent( + traffic_discount_percent = _get_addon_discount_percent( user, promo_group, "traffic", @@ -868,7 +889,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _get_discount_percent( + devices_discount_percent = _get_addon_discount_percent( user, promo_group, "devices", @@ -892,12 +913,19 @@ async def calculate_addon_cost_for_remaining_period( for server_id in additional_server_ids: result = await db.execute( select(ServerSquad.price_kopeks, ServerSquad.display_name) - .where(ServerSquad.id == server_id) + .where( + ServerSquad.id == server_id, + ServerSquad.is_available.is_(True), + or_( + ServerSquad.max_users.is_(None), + ServerSquad.current_users < ServerSquad.max_users, + ), + ) ) server_data = result.first() if server_data: server_price_per_month, server_name = server_data - servers_discount_percent = _get_discount_percent( + servers_discount_percent = _get_addon_discount_percent( user, promo_group, "servers", diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 97b91d0b..d08f0b48 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -93,6 +93,51 @@ def _apply_discount_to_monthly_component( } +def _get_addon_discount_percent_for_user( + user: User, + category: str, + period_days: Optional[int] = None, +) -> int: + promo_group = getattr(user, "promo_group", None) + + if promo_group is not None and not getattr(promo_group, "apply_discounts_to_addons", True): + return 0 + + try: + return user.get_promo_discount(category, period_days) + except AttributeError: + return 0 + + +def _calculate_discounted_addon_price( + subscription: Subscription, + user: User, + base_price_per_month: int, + category: str, +) -> Dict[str, int]: + months_to_pay = max(1, get_remaining_months(subscription.end_date)) + period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + discount_percent = _get_addon_discount_percent_for_user( + user, + category, + period_hint_days, + ) + + discount_per_month = base_price_per_month * discount_percent // 100 + discounted_per_month = base_price_per_month - discount_per_month + total_price = discounted_per_month * months_to_pay + total_discount = discount_per_month * months_to_pay + + return { + "total_price": total_price, + "charged_months": months_to_pay, + "discount_percent": discount_percent, + "discount_total": total_discount, + "discount_per_month": discount_per_month, + "discounted_per_month": discounted_per_month, + } + + async def _prepare_subscription_summary( db_user: User, data: Dict[str, Any], @@ -1287,35 +1332,64 @@ async def apply_countries_changes( logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") - months_to_pay = get_remaining_months(subscription.end_date) - + months_to_pay = max(1, get_remaining_months(subscription.end_date)) + period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + servers_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "servers", + period_hint_days, + ) + cost_per_month = 0 added_names = [] removed_names = [] - + added_server_prices = [] - + total_cost = 0 + total_discount = 0 + for country in countries: if country['uuid'] in added: server_price_per_month = country['price_kopeks'] cost_per_month += server_price_per_month added_names.append(country['name']) + server_discount_per_month = ( + server_price_per_month * servers_discount_percent // 100 + ) + discounted_per_month = server_price_per_month - server_discount_per_month + server_total_price = discounted_per_month * months_to_pay + added_server_prices.append(server_total_price) + total_cost += server_total_price + total_discount += server_discount_per_month * months_to_pay if country['uuid'] in removed: removed_names.append(country['name']) - - total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) - - for country in countries: - if country['uuid'] in added: - server_price_per_month = country['price_kopeks'] - server_total_price = server_price_per_month * charged_months - added_server_prices.append(server_total_price) - - logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽") - + + charged_months = months_to_pay + + if added and servers_discount_percent > 0: + logger.info( + "Стоимость новых серверов: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽)", + cost_per_month / 100, + charged_months, + total_cost / 100, + servers_discount_percent, + total_discount / 100, + ) + else: + logger.info( + "Стоимость новых серверов: %s₽/мес × %s мес = %s₽", + cost_per_month / 100, + charged_months, + total_cost / 100, + ) + if total_cost > 0 and db_user.balance_kopeks < total_cost: missing_kopeks = total_cost - db_user.balance_kopeks required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)" + if total_discount > 0: + required_text += ( + f"\n💸 Скидка {servers_discount_percent}%: -{texts.format_price(total_discount)}" + ) message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -1398,6 +1472,10 @@ async def apply_countries_changes( success_text += "\n".join(f"• {name}" for name in added_names) if total_cost > 0: success_text += f"\n💰 Списано: {texts.format_price(total_cost)} (за {charged_months} мес)" + if total_discount > 0: + success_text += ( + f"\n💸 Скидка {servers_discount_percent}%: -{texts.format_price(total_discount)}" + ) success_text += "\n" if removed_names: @@ -1493,8 +1571,6 @@ async def confirm_change_devices( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price - new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -1522,13 +1598,26 @@ async def confirm_change_devices( chargeable_devices = max(0, additional_devices - free_devices) else: chargeable_devices = additional_devices - + devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE - price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) - + pricing = _calculate_discounted_addon_price( + subscription, + db_user, + devices_price_per_month, + "devices", + ) + price = pricing["total_price"] + charged_months = pricing["charged_months"] + discount_percent = pricing["discount_percent"] + discount_total = pricing["discount_total"] + if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" + if discount_total > 0: + required_text += ( + f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" + ) message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -1554,9 +1643,13 @@ async def confirm_change_devices( ) await callback.answer() return - + action_text = f"увеличить до {new_devices_count}" cost_text = f"Доплата: {texts.format_price(price)} (за {charged_months} мес)" if price > 0 else "Бесплатно" + if price > 0 and discount_total > 0: + cost_text += ( + f" (скидка {discount_percent}%: -{texts.format_price(discount_total)})" + ) else: price = 0 @@ -1605,7 +1698,7 @@ async def execute_change_devices( await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - charged_months = get_remaining_months(subscription.end_date) + charged_months = max(1, get_remaining_months(subscription.end_date)) await create_transaction( db=db, user_id=db_user.id, @@ -2120,8 +2213,6 @@ async def confirm_add_devices( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price - devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -2139,13 +2230,43 @@ async def confirm_add_devices( return devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE - price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) - - logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽") + pricing = _calculate_discounted_addon_price( + subscription, + db_user, + devices_price_per_month, + "devices", + ) + price = pricing["total_price"] + charged_months = pricing["charged_months"] + discount_percent = pricing["discount_percent"] + discount_total = pricing["discount_total"] + + if discount_percent > 0: + logger.info( + "Добавление %s устройств: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽)", + devices_count, + devices_price_per_month / 100, + charged_months, + price / 100, + discount_percent, + discount_total / 100, + ) + else: + logger.info( + "Добавление %s устройств: %s₽/мес × %s мес = %s₽", + devices_count, + devices_price_per_month / 100, + charged_months, + price / 100, + ) if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" + if discount_total > 0: + required_text += ( + f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" + ) message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -2204,7 +2325,12 @@ async def confirm_add_devices( f"✅ Устройства успешно добавлены!\n\n" f"📱 Добавлено: {devices_count} устройств\n" f"Новый лимит: {subscription.device_limit} устройств\n" - f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)", + f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)" + + ( + f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" + if discount_total > 0 + else "" + ), reply_markup=get_back_keyboard(db_user.language) ) @@ -3501,12 +3627,42 @@ async def add_traffic( texts = get_texts(db_user.language) subscription = db_user.subscription - price = settings.get_traffic_price(traffic_gb) - - if price == 0 and traffic_gb != 0: + price_per_month = settings.get_traffic_price(traffic_gb) + + if price_per_month == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) return - + + pricing = _calculate_discounted_addon_price( + subscription, + db_user, + price_per_month, + "traffic", + ) + price = pricing["total_price"] + charged_months = pricing["charged_months"] + discount_percent = pricing["discount_percent"] + discount_total = pricing["discount_total"] + + if discount_percent > 0: + logger.info( + "Добавление трафика +%s ГБ: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽)", + traffic_gb, + price_per_month / 100, + charged_months, + price / 100, + discount_percent, + discount_total / 100, + ) + else: + logger.info( + "Добавление трафика +%s ГБ: %s₽/мес × %s мес = %s₽", + traffic_gb, + price_per_month / 100, + charged_months, + price / 100, + ) + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks message_text = texts.t( @@ -3519,7 +3675,14 @@ async def add_traffic( "Выберите способ пополнения. Сумма подставится автоматически." ), ).format( - required=texts.format_price(price), + required=( + f"{texts.format_price(price)} (за {charged_months} мес)" + + ( + f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" + if discount_total > 0 + else "" + ) + ), balance=texts.format_price(db_user.balance_kopeks), missing=texts.format_price(missing_kopeks), ) @@ -3538,7 +3701,7 @@ async def add_traffic( try: success = await subtract_user_balance( db, db_user, price, - f"Добавление {traffic_gb} ГБ трафика" + f"Добавление {traffic_gb} ГБ трафика на {charged_months} мес" ) if not success: @@ -3558,7 +3721,9 @@ async def add_traffic( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price, - description=f"Добавление {traffic_gb} ГБ трафика" + description=( + f"Добавление {traffic_gb} ГБ трафика на {charged_months} мес" + ) ) @@ -3571,6 +3736,12 @@ async def add_traffic( else: success_text += f"📈 Добавлено: {traffic_gb} ГБ\n" success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}" + if price > 0: + success_text += f"\n💰 Списано: {texts.format_price(price)} (за {charged_months} мес)" + if discount_total > 0: + success_text += ( + f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" + ) await callback.message.edit_text( success_text, diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 54ba6720..4749b11a 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -14,7 +14,8 @@ from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, calculate_prorated_price, - validate_pricing_calculation + validate_pricing_calculation, + resolve_addon_discount_percent, ) logger = logging.getLogger(__name__) @@ -48,12 +49,9 @@ def _resolve_addon_discount_percent( ) -> int: group = promo_group or (getattr(user, "promo_group", None) if user else None) - if group is not None and not getattr(group, "apply_discounts_to_addons", True): - return 0 - - return _resolve_discount_percent( + return resolve_addon_discount_percent( user, - promo_group, + group, category, period_days=period_days, ) diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index 40d7f589..ead9ddd4 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,10 +1,14 @@ from datetime import datetime, timedelta -from typing import Tuple +from typing import Tuple, Optional, TYPE_CHECKING import logging logger = logging.getLogger(__name__) +if TYPE_CHECKING: # pragma: no cover + from app.database.models import User, PromoGroup + + def calculate_months_from_days(days: int) -> int: return max(1, round(days / 30)) @@ -61,6 +65,32 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]: return discounted_amount, discount_value +def resolve_addon_discount_percent( + user: Optional["User"], + promo_group: Optional["PromoGroup"], + category: str, + *, + period_days: Optional[int] = None, +) -> int: + """Return discount percent for add-on purchases respecting promo-group rules.""" + + group = promo_group or (getattr(user, "promo_group", None) if user else None) + + if group is not None and not getattr(group, "apply_discounts_to_addons", True): + return 0 + + if user is not None: + try: + return user.get_promo_discount(category, period_days) + except AttributeError: + pass + + if promo_group is not None: + return promo_group.get_discount_percent(category, period_days) + + return 0 + + def format_period_description(days: int, language: str = "ru") -> str: months = calculate_months_from_days(days) From 2e71fd89c995f97236b8876ad671f7015ec30f9e Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 13:51:46 +0300 Subject: [PATCH 121/146] Revert "Fix addon discounts for server/device/traffic purchases" --- app/database/crud/subscription.py | 42 +---- app/handlers/subscription.py | 241 ++++----------------------- app/services/subscription_service.py | 10 +- app/utils/pricing_utils.py | 32 +--- 4 files changed, 49 insertions(+), 276 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index cd8b5378..91b79375 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1,7 +1,7 @@ import logging from datetime import datetime, timedelta from typing import Optional, List, Tuple -from sqlalchemy import select, and_, func, or_ +from sqlalchemy import select, and_, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -13,11 +13,7 @@ from app.database.models import ( PromoGroup, ) from app.database.crud.notification import clear_notifications -from app.utils.pricing_utils import ( - calculate_months_from_days, - get_remaining_months, - resolve_addon_discount_percent, -) +from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months from app.config import settings logger = logging.getLogger(__name__) @@ -521,23 +517,6 @@ def _get_discount_percent( return 0 -def _get_addon_discount_percent( - user: Optional[User], - promo_group: Optional[PromoGroup], - category: str, - *, - period_days: Optional[int] = None, -) -> int: - group = promo_group or (getattr(user, "promo_group", None) if user else None) - - return resolve_addon_discount_percent( - user, - group, - category, - period_days=period_days, - ) - - async def calculate_subscription_total_cost( db: AsyncSession, period_days: int, @@ -857,7 +836,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_server_ids is None: additional_server_ids = [] - months_to_pay = max(1, get_remaining_months(subscription.end_date)) + months_to_pay = get_remaining_months(subscription.end_date) period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None total_cost = 0 @@ -868,7 +847,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_discount_percent = _get_addon_discount_percent( + traffic_discount_percent = _get_discount_percent( user, promo_group, "traffic", @@ -889,7 +868,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _get_addon_discount_percent( + devices_discount_percent = _get_discount_percent( user, promo_group, "devices", @@ -913,19 +892,12 @@ async def calculate_addon_cost_for_remaining_period( for server_id in additional_server_ids: result = await db.execute( select(ServerSquad.price_kopeks, ServerSquad.display_name) - .where( - ServerSquad.id == server_id, - ServerSquad.is_available.is_(True), - or_( - ServerSquad.max_users.is_(None), - ServerSquad.current_users < ServerSquad.max_users, - ), - ) + .where(ServerSquad.id == server_id) ) server_data = result.first() if server_data: server_price_per_month, server_name = server_data - servers_discount_percent = _get_addon_discount_percent( + servers_discount_percent = _get_discount_percent( user, promo_group, "servers", diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index d08f0b48..97b91d0b 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -93,51 +93,6 @@ def _apply_discount_to_monthly_component( } -def _get_addon_discount_percent_for_user( - user: User, - category: str, - period_days: Optional[int] = None, -) -> int: - promo_group = getattr(user, "promo_group", None) - - if promo_group is not None and not getattr(promo_group, "apply_discounts_to_addons", True): - return 0 - - try: - return user.get_promo_discount(category, period_days) - except AttributeError: - return 0 - - -def _calculate_discounted_addon_price( - subscription: Subscription, - user: User, - base_price_per_month: int, - category: str, -) -> Dict[str, int]: - months_to_pay = max(1, get_remaining_months(subscription.end_date)) - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None - discount_percent = _get_addon_discount_percent_for_user( - user, - category, - period_hint_days, - ) - - discount_per_month = base_price_per_month * discount_percent // 100 - discounted_per_month = base_price_per_month - discount_per_month - total_price = discounted_per_month * months_to_pay - total_discount = discount_per_month * months_to_pay - - return { - "total_price": total_price, - "charged_months": months_to_pay, - "discount_percent": discount_percent, - "discount_total": total_discount, - "discount_per_month": discount_per_month, - "discounted_per_month": discounted_per_month, - } - - async def _prepare_subscription_summary( db_user: User, data: Dict[str, Any], @@ -1332,64 +1287,35 @@ async def apply_countries_changes( logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") - months_to_pay = max(1, get_remaining_months(subscription.end_date)) - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None - servers_discount_percent = _get_addon_discount_percent_for_user( - db_user, - "servers", - period_hint_days, - ) - + months_to_pay = get_remaining_months(subscription.end_date) + cost_per_month = 0 added_names = [] removed_names = [] - + added_server_prices = [] - total_cost = 0 - total_discount = 0 - + for country in countries: if country['uuid'] in added: server_price_per_month = country['price_kopeks'] cost_per_month += server_price_per_month added_names.append(country['name']) - server_discount_per_month = ( - server_price_per_month * servers_discount_percent // 100 - ) - discounted_per_month = server_price_per_month - server_discount_per_month - server_total_price = discounted_per_month * months_to_pay - added_server_prices.append(server_total_price) - total_cost += server_total_price - total_discount += server_discount_per_month * months_to_pay if country['uuid'] in removed: removed_names.append(country['name']) - - charged_months = months_to_pay - - if added and servers_discount_percent > 0: - logger.info( - "Стоимость новых серверов: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽)", - cost_per_month / 100, - charged_months, - total_cost / 100, - servers_discount_percent, - total_discount / 100, - ) - else: - logger.info( - "Стоимость новых серверов: %s₽/мес × %s мес = %s₽", - cost_per_month / 100, - charged_months, - total_cost / 100, - ) - + + total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) + + for country in countries: + if country['uuid'] in added: + server_price_per_month = country['price_kopeks'] + server_total_price = server_price_per_month * charged_months + added_server_prices.append(server_total_price) + + logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽") + if total_cost > 0 and db_user.balance_kopeks < total_cost: missing_kopeks = total_cost - db_user.balance_kopeks required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)" - if total_discount > 0: - required_text += ( - f"\n💸 Скидка {servers_discount_percent}%: -{texts.format_price(total_discount)}" - ) message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -1472,10 +1398,6 @@ async def apply_countries_changes( success_text += "\n".join(f"• {name}" for name in added_names) if total_cost > 0: success_text += f"\n💰 Списано: {texts.format_price(total_cost)} (за {charged_months} мес)" - if total_discount > 0: - success_text += ( - f"\n💸 Скидка {servers_discount_percent}%: -{texts.format_price(total_discount)}" - ) success_text += "\n" if removed_names: @@ -1571,6 +1493,8 @@ async def confirm_change_devices( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price + new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -1598,26 +1522,13 @@ async def confirm_change_devices( chargeable_devices = max(0, additional_devices - free_devices) else: chargeable_devices = additional_devices - + devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE - pricing = _calculate_discounted_addon_price( - subscription, - db_user, - devices_price_per_month, - "devices", - ) - price = pricing["total_price"] - charged_months = pricing["charged_months"] - discount_percent = pricing["discount_percent"] - discount_total = pricing["discount_total"] - + price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) + if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" - if discount_total > 0: - required_text += ( - f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" - ) message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -1643,13 +1554,9 @@ async def confirm_change_devices( ) await callback.answer() return - + action_text = f"увеличить до {new_devices_count}" cost_text = f"Доплата: {texts.format_price(price)} (за {charged_months} мес)" if price > 0 else "Бесплатно" - if price > 0 and discount_total > 0: - cost_text += ( - f" (скидка {discount_percent}%: -{texts.format_price(discount_total)})" - ) else: price = 0 @@ -1698,7 +1605,7 @@ async def execute_change_devices( await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - charged_months = max(1, get_remaining_months(subscription.end_date)) + charged_months = get_remaining_months(subscription.end_date) await create_transaction( db=db, user_id=db_user.id, @@ -2213,6 +2120,8 @@ async def confirm_add_devices( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price + devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -2230,43 +2139,13 @@ async def confirm_add_devices( return devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE - pricing = _calculate_discounted_addon_price( - subscription, - db_user, - devices_price_per_month, - "devices", - ) - price = pricing["total_price"] - charged_months = pricing["charged_months"] - discount_percent = pricing["discount_percent"] - discount_total = pricing["discount_total"] - - if discount_percent > 0: - logger.info( - "Добавление %s устройств: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽)", - devices_count, - devices_price_per_month / 100, - charged_months, - price / 100, - discount_percent, - discount_total / 100, - ) - else: - logger.info( - "Добавление %s устройств: %s₽/мес × %s мес = %s₽", - devices_count, - devices_price_per_month / 100, - charged_months, - price / 100, - ) + price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) + + logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽") if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" - if discount_total > 0: - required_text += ( - f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" - ) message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -2325,12 +2204,7 @@ async def confirm_add_devices( f"✅ Устройства успешно добавлены!\n\n" f"📱 Добавлено: {devices_count} устройств\n" f"Новый лимит: {subscription.device_limit} устройств\n" - f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)" - + ( - f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" - if discount_total > 0 - else "" - ), + f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)", reply_markup=get_back_keyboard(db_user.language) ) @@ -3627,42 +3501,12 @@ async def add_traffic( texts = get_texts(db_user.language) subscription = db_user.subscription - price_per_month = settings.get_traffic_price(traffic_gb) - - if price_per_month == 0 and traffic_gb != 0: + price = settings.get_traffic_price(traffic_gb) + + if price == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) return - - pricing = _calculate_discounted_addon_price( - subscription, - db_user, - price_per_month, - "traffic", - ) - price = pricing["total_price"] - charged_months = pricing["charged_months"] - discount_percent = pricing["discount_percent"] - discount_total = pricing["discount_total"] - - if discount_percent > 0: - logger.info( - "Добавление трафика +%s ГБ: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽)", - traffic_gb, - price_per_month / 100, - charged_months, - price / 100, - discount_percent, - discount_total / 100, - ) - else: - logger.info( - "Добавление трафика +%s ГБ: %s₽/мес × %s мес = %s₽", - traffic_gb, - price_per_month / 100, - charged_months, - price / 100, - ) - + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks message_text = texts.t( @@ -3675,14 +3519,7 @@ async def add_traffic( "Выберите способ пополнения. Сумма подставится автоматически." ), ).format( - required=( - f"{texts.format_price(price)} (за {charged_months} мес)" - + ( - f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" - if discount_total > 0 - else "" - ) - ), + required=texts.format_price(price), balance=texts.format_price(db_user.balance_kopeks), missing=texts.format_price(missing_kopeks), ) @@ -3701,7 +3538,7 @@ async def add_traffic( try: success = await subtract_user_balance( db, db_user, price, - f"Добавление {traffic_gb} ГБ трафика на {charged_months} мес" + f"Добавление {traffic_gb} ГБ трафика" ) if not success: @@ -3721,9 +3558,7 @@ async def add_traffic( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price, - description=( - f"Добавление {traffic_gb} ГБ трафика на {charged_months} мес" - ) + description=f"Добавление {traffic_gb} ГБ трафика" ) @@ -3736,12 +3571,6 @@ async def add_traffic( else: success_text += f"📈 Добавлено: {traffic_gb} ГБ\n" success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}" - if price > 0: - success_text += f"\n💰 Списано: {texts.format_price(price)} (за {charged_months} мес)" - if discount_total > 0: - success_text += ( - f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" - ) await callback.message.edit_text( success_text, diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 4749b11a..54ba6720 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -14,8 +14,7 @@ from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, calculate_prorated_price, - validate_pricing_calculation, - resolve_addon_discount_percent, + validate_pricing_calculation ) logger = logging.getLogger(__name__) @@ -49,9 +48,12 @@ def _resolve_addon_discount_percent( ) -> int: group = promo_group or (getattr(user, "promo_group", None) if user else None) - return resolve_addon_discount_percent( + if group is not None and not getattr(group, "apply_discounts_to_addons", True): + return 0 + + return _resolve_discount_percent( user, - group, + promo_group, category, period_days=period_days, ) diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index ead9ddd4..40d7f589 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,14 +1,10 @@ from datetime import datetime, timedelta -from typing import Tuple, Optional, TYPE_CHECKING +from typing import Tuple import logging logger = logging.getLogger(__name__) -if TYPE_CHECKING: # pragma: no cover - from app.database.models import User, PromoGroup - - def calculate_months_from_days(days: int) -> int: return max(1, round(days / 30)) @@ -65,32 +61,6 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]: return discounted_amount, discount_value -def resolve_addon_discount_percent( - user: Optional["User"], - promo_group: Optional["PromoGroup"], - category: str, - *, - period_days: Optional[int] = None, -) -> int: - """Return discount percent for add-on purchases respecting promo-group rules.""" - - group = promo_group or (getattr(user, "promo_group", None) if user else None) - - if group is not None and not getattr(group, "apply_discounts_to_addons", True): - return 0 - - if user is not None: - try: - return user.get_promo_discount(category, period_days) - except AttributeError: - pass - - if promo_group is not None: - return promo_group.get_discount_percent(category, period_days) - - return 0 - - def format_period_description(days: int, language: str = "ru") -> str: months = calculate_months_from_days(days) From 4a94d6a9667c0131d8f2da50fd75b28a86df4326 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 13:52:26 +0300 Subject: [PATCH 122/146] Fix addon discounts for servers, devices, and traffic --- app/handlers/subscription.py | 330 +++++++++++++++++++++------ app/keyboards/inline.py | 34 ++- app/services/subscription_service.py | 8 +- 3 files changed, 284 insertions(+), 88 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 97b91d0b..c73261f5 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -6,7 +6,7 @@ from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession import json import os -from typing import Dict, List, Any, Tuple, Optional +from typing import Dict, List, Any, Tuple, Optional, Set from app.config import settings, PERIOD_PRICES, get_traffic_prices from app.states import SubscriptionStates @@ -49,7 +49,7 @@ from app.keyboards.inline import ( from app.localization.texts import get_texts from app.services.remnawave_service import RemnaWaveService from app.services.admin_notification_service import AdminNotificationService -from app.services.subscription_service import SubscriptionService +from app.services.subscription_service import SubscriptionService, resolve_addon_discount_percent from app.services.subscription_checkout_service import ( clear_subscription_checkout_draft, get_subscription_checkout_draft, @@ -62,6 +62,7 @@ from app.utils.pricing_utils import ( calculate_prorated_price, validate_pricing_calculation, format_period_description, + apply_percentage_discount, ) from app.utils.pagination import paginate_list from app.utils.subscription_utils import ( @@ -1137,14 +1138,22 @@ async def handle_add_countries( text += "⚪ - не выбрана\n\n" text += "⚠️ Важно: Повторное подключение отключенных стран будет платным!" + ( + countries_with_pricing, + _country_map, + _available_country_ids, + _servers_discount_percent, + _months_to_pay, + ) = _prepare_countries_with_addon_pricing(db_user, countries) + await state.update_data(countries=current_countries.copy()) - + await callback.message.edit_text( text, reply_markup=get_manage_countries_keyboard( - countries, - current_countries.copy(), - current_countries, + countries_with_pricing, + current_countries.copy(), + current_countries, db_user.language, subscription.end_date ), @@ -1208,10 +1217,19 @@ async def handle_manage_country( return data = await state.get_data() - current_selected = data.get('countries', subscription.connected_squads.copy()) + current_countries = subscription.connected_squads + current_selected = data.get('countries', current_countries.copy()) countries = await _get_available_countries(db_user.promo_group_id) - allowed_country_ids = {country['uuid'] for country in countries} + ( + countries_with_pricing, + _country_map, + available_country_ids, + _servers_discount_percent, + _months_to_pay, + ) = _prepare_countries_with_addon_pricing(db_user, countries) + + allowed_country_ids = available_country_ids | set(current_countries) if country_uuid not in allowed_country_ids and country_uuid not in current_selected: await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) @@ -1231,11 +1249,11 @@ async def handle_manage_country( try: await callback.message.edit_reply_markup( reply_markup=get_manage_countries_keyboard( - countries, - current_selected, - subscription.connected_squads, + countries_with_pricing, + current_selected, + current_countries, db_user.language, - subscription.end_date + subscription.end_date ) ) logger.info(f"✅ Клавиатура обновлена") @@ -1270,7 +1288,15 @@ async def apply_countries_changes( current_countries = subscription.connected_squads countries = await _get_available_countries(db_user.promo_group_id) - allowed_country_ids = {country['uuid'] for country in countries} + ( + countries_with_pricing, + _country_map, + available_country_ids, + servers_discount_percent, + months_to_pay, + ) = _prepare_countries_with_addon_pricing(db_user, countries) + + allowed_country_ids = available_country_ids | set(current_countries) selected_countries = [ country_uuid @@ -1278,7 +1304,10 @@ async def apply_countries_changes( if country_uuid in allowed_country_ids or country_uuid in current_countries ] - added = [c for c in selected_countries if c not in current_countries] + added = [ + c for c in selected_countries + if c not in current_countries and c in available_country_ids + ] removed = [c for c in current_countries if c not in selected_countries] if not added and not removed: @@ -1287,31 +1316,37 @@ async def apply_countries_changes( logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") - months_to_pay = get_remaining_months(subscription.end_date) - cost_per_month = 0 added_names = [] removed_names = [] - + added_server_prices = [] - - for country in countries: + + for country in countries_with_pricing: if country['uuid'] in added: - server_price_per_month = country['price_kopeks'] + server_price_per_month = country.get('discounted_price_kopeks', country['price_kopeks']) cost_per_month += server_price_per_month added_names.append(country['name']) if country['uuid'] in removed: removed_names.append(country['name']) - - total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) - - for country in countries: + + charged_months = months_to_pay + total_cost = cost_per_month * charged_months + + for country in countries_with_pricing: if country['uuid'] in added: - server_price_per_month = country['price_kopeks'] + server_price_per_month = country.get('discounted_price_kopeks', country['price_kopeks']) server_total_price = server_price_per_month * charged_months added_server_prices.append(server_total_price) - - logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽") + + if cost_per_month > 0: + logger.info( + "Стоимость новых серверов: %s₽/мес × %s мес = %s₽ (скидка %s%%)", + cost_per_month / 100, + charged_months, + total_cost / 100, + servers_discount_percent, + ) if total_cost > 0 and db_user.balance_kopeks < total_cost: missing_kopeks = total_cost - db_user.balance_kopeks @@ -1346,7 +1381,7 @@ async def apply_countries_changes( try: if added and total_cost > 0: success = await subtract_user_balance( - db, db_user, total_cost, + db, db_user, total_cost, f"Добавление стран: {', '.join(added_names)} на {charged_months} мес" ) if not success: @@ -1524,8 +1559,22 @@ async def confirm_change_devices( chargeable_devices = additional_devices devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE - price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) - + end_date = getattr(subscription, "end_date", None) + months_to_pay = get_remaining_months(end_date) if end_date else 1 + period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + devices_discount_percent = resolve_addon_discount_percent( + db_user, + getattr(db_user, "promo_group", None), + "devices", + period_days=period_hint_days, + ) + discounted_devices_price_per_month, discount_per_month = apply_percentage_discount( + devices_price_per_month, + devices_discount_percent, + ) + price = discounted_devices_price_per_month * months_to_pay + charged_months = months_to_pay + if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" @@ -2139,10 +2188,32 @@ async def confirm_add_devices( return devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE - price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) - - logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽") - + end_date = getattr(subscription, "end_date", None) + months_to_pay = get_remaining_months(end_date) if end_date else 1 + period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + devices_discount_percent = resolve_addon_discount_percent( + db_user, + getattr(db_user, "promo_group", None), + "devices", + period_days=period_hint_days, + ) + discounted_devices_price_per_month, discount_per_month = apply_percentage_discount( + devices_price_per_month, + devices_discount_percent, + ) + price = discounted_devices_price_per_month * months_to_pay + charged_months = months_to_pay + + logger.info( + "Добавление %s устройств: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽/мес)", + devices_count, + devices_price_per_month / 100, + charged_months, + price / 100, + devices_discount_percent, + discount_per_month / 100, + ) + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" @@ -3501,14 +3572,29 @@ async def add_traffic( texts = get_texts(db_user.language) subscription = db_user.subscription - price = settings.get_traffic_price(traffic_gb) - - if price == 0 and traffic_gb != 0: + price_per_month = settings.get_traffic_price(traffic_gb) + + if price_per_month == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) return - - if db_user.balance_kopeks < price: - missing_kopeks = price - db_user.balance_kopeks + + end_date = getattr(subscription, "end_date", None) + months_to_pay = get_remaining_months(end_date) if end_date else 1 + period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + traffic_discount_percent = resolve_addon_discount_percent( + db_user, + getattr(db_user, "promo_group", None), + "traffic", + period_days=period_hint_days, + ) + discounted_price_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + traffic_discount_percent, + ) + total_price = discounted_price_per_month * months_to_pay + + if db_user.balance_kopeks < total_price: + missing_kopeks = total_price - db_user.balance_kopeks message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -3519,7 +3605,7 @@ async def add_traffic( "Выберите способ пополнения. Сумма подставится автоматически." ), ).format( - required=texts.format_price(price), + required=f"{texts.format_price(total_price)} (за {months_to_pay} мес)", balance=texts.format_price(db_user.balance_kopeks), missing=texts.format_price(missing_kopeks), ) @@ -3534,13 +3620,13 @@ async def add_traffic( ) await callback.answer() return - + try: success = await subtract_user_balance( - db, db_user, price, + db, db_user, total_price, f"Добавление {traffic_gb} ГБ трафика" ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return @@ -3557,28 +3643,45 @@ async def add_traffic( db=db, user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=price, + amount_kopeks=total_price, description=f"Добавление {traffic_gb} ГБ трафика" ) - - + + await db.refresh(db_user) await db.refresh(subscription) - + success_text = f"✅ Трафик успешно добавлен!\n\n" if traffic_gb == 0: success_text += "🎉 Теперь у вас безлимитный трафик!" else: success_text += f"📈 Добавлено: {traffic_gb} ГБ\n" success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}" - + if total_price > 0: + success_text += f"\n💰 Списано: {texts.format_price(total_price)} (за {months_to_pay} мес)" + if total_price > 0 and traffic_discount_percent > 0: + saved_total = discount_per_month * months_to_pay + if saved_total > 0: + success_text += ( + f"\n💸 Применена скидка {traffic_discount_percent}%" + f" (экономия {texts.format_price(saved_total)})" + ) + await callback.message.edit_text( success_text, reply_markup=get_back_keyboard(db_user.language) ) - - logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика") - + + logger.info( + "✅ Пользователь %s добавил %s ГБ трафика: %s₽/мес × %s мес = %s₽ (скидка %s%%)", + db_user.telegram_id, + traffic_gb, + price_per_month / 100, + months_to_pay, + total_price / 100, + traffic_discount_percent, + ) + except Exception as e: logger.error(f"Ошибка добавления трафика: {e}") await callback.message.edit_text( @@ -3834,8 +3937,9 @@ async def _get_available_countries(promo_group_id: Optional[int] = None): countries = [] for server in available_servers: countries.append({ + "id": server.id, "uuid": server.squad_uuid, - "name": server.display_name, + "name": server.display_name, "price_kopeks": server.price_kopeks, "country_code": server.country_code, "is_available": server.is_available and not server.is_full @@ -3863,9 +3967,10 @@ async def _get_available_countries(promo_group_id: Optional[int] = None): squad_name = f"🌐 {squad_name}" countries.append({ + "id": None, "uuid": squad["uuid"], "name": squad_name, - "price_kopeks": 0, + "price_kopeks": 0, "is_available": True }) @@ -3885,6 +3990,50 @@ async def _get_countries_info(squad_uuids): countries = await _get_available_countries() return [c for c in countries if c['uuid'] in squad_uuids] + +def _prepare_countries_with_addon_pricing( + user: User, + countries: List[dict], +) -> Tuple[List[dict], Dict[str, dict], Set[str], int, int]: + subscription = user.subscription + end_date = getattr(subscription, "end_date", None) if subscription else None + months_to_pay = get_remaining_months(end_date) if end_date else 1 + period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + servers_discount_percent = resolve_addon_discount_percent( + user, + getattr(user, "promo_group", None), + "servers", + period_days=period_hint_days, + ) + + countries_with_pricing: List[dict] = [] + country_map: Dict[str, dict] = {} + available_country_ids: Set[str] = set() + + for country in countries: + country_copy = dict(country) + price_per_month = country_copy.get("price_kopeks", 0) + discounted_price, _ = apply_percentage_discount( + price_per_month, + servers_discount_percent, + ) + country_copy["discounted_price_kopeks"] = discounted_price + country_copy["discount_percent"] = servers_discount_percent + + if country_copy.get("is_available", True): + available_country_ids.add(country_copy["uuid"]) + + countries_with_pricing.append(country_copy) + country_map[country_copy["uuid"]] = country_copy + + return ( + countries_with_pricing, + country_map, + available_country_ids, + servers_discount_percent, + months_to_pay, + ) + async def handle_reset_devices( callback: types.CallbackQuery, db_user: User, @@ -3912,7 +4061,17 @@ async def handle_add_country_to_subscription( selected_countries = data.get('countries', []) countries = await _get_available_countries(db_user.promo_group_id) - allowed_country_ids = {country['uuid'] for country in countries} + + ( + countries_with_pricing, + country_map, + available_country_ids, + servers_discount_percent, + months_to_pay, + ) = _prepare_countries_with_addon_pricing(db_user, countries) + + subscription = db_user.subscription + allowed_country_ids = available_country_ids | set(subscription.connected_squads) if country_uuid not in allowed_country_ids and country_uuid not in selected_countries: await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) @@ -3926,21 +4085,31 @@ async def handle_add_country_to_subscription( logger.info(f"🔍 Добавлена страна: {country_uuid}") total_price = 0 - for country in countries: - if country['uuid'] in selected_countries and country['uuid'] not in db_user.subscription.connected_squads: - total_price += country['price_kopeks'] - + for uuid in selected_countries: + if uuid in country_map and uuid not in subscription.connected_squads and uuid in available_country_ids: + country_data = country_map[uuid] + discounted_price = country_data.get("discounted_price_kopeks", country_data.get("price_kopeks", 0)) + total_price += discounted_price * months_to_pay + data['countries'] = selected_countries data['total_price'] = total_price + data['servers_discount_percent'] = servers_discount_percent + data['months_to_pay'] = months_to_pay await state.set_data(data) - + logger.info(f"🔍 Новые выбранные страны: {selected_countries}") logger.info(f"🔍 Общая стоимость: {total_price}") - + try: from app.keyboards.inline import get_manage_countries_keyboard await callback.message.edit_reply_markup( - reply_markup=get_manage_countries_keyboard(countries, selected_countries, db_user.subscription.connected_squads, db_user.language) + reply_markup=get_manage_countries_keyboard( + countries_with_pricing, + selected_countries, + db_user.subscription.connected_squads, + db_user.language, + subscription_end_date=subscription.end_date, + ) ) logger.info(f"✅ Клавиатура обновлена") except Exception as e: @@ -3982,24 +4151,36 @@ async def confirm_add_countries_to_subscription( if country_uuid in allowed_country_ids or country_uuid in current_countries ] - new_countries = [c for c in selected_countries if c not in current_countries] + new_countries = [ + c for c in selected_countries + if c not in current_countries and c in available_country_ids + ] removed_countries = [c for c in current_countries if c not in selected_countries] - + if not new_countries and not removed_countries: await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) return - + total_price = 0 new_countries_names = [] removed_countries_names = [] - - for country in countries: - if country['uuid'] in new_countries: - total_price += country['price_kopeks'] - new_countries_names.append(country['name']) - if country['uuid'] in removed_countries: + + for uuid in new_countries: + country = country_map.get(uuid) + if not country: + continue + discounted_price = country.get( + 'discounted_price_kopeks', + country.get('price_kopeks', 0), + ) + total_price += discounted_price * months_to_pay + new_countries_names.append(country['name']) + + for uuid in removed_countries: + country = country_map.get(uuid) + if country: removed_countries_names.append(country['name']) - + if new_countries and db_user.balance_kopeks < total_price: missing_kopeks = total_price - db_user.balance_kopeks message_text = texts.t( @@ -4063,7 +4244,10 @@ async def confirm_add_countries_to_subscription( if new_countries_names: success_text += f"➕ Добавлены страны:\n{chr(10).join(f'• {name}' for name in new_countries_names)}\n" if total_price > 0: - success_text += f"💰 Списано: {texts.format_price(total_price)}\n" + success_text += ( + f"💰 Списано: {texts.format_price(total_price)}" + f" (за {months_to_pay} мес)\n" + ) if removed_countries_names: success_text += f"\n➖ Отключены страны:\n{chr(10).join(f'• {name}' for name in removed_countries_names)}\n" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index adb4462f..80bbf89d 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1313,8 +1313,10 @@ def get_manage_countries_keyboard( for country in countries: uuid = country['uuid'] name = country['name'] - price_per_month = country['price_kopeks'] - + price_per_month = country.get('price_kopeks', 0) + discounted_price_per_month = country.get('discounted_price_kopeks', price_per_month) + discount_percent = country.get('discount_percent', 0) + if uuid in current_subscription_countries: if uuid in selected: icon = "✅" @@ -1323,31 +1325,41 @@ def get_manage_countries_keyboard( else: if uuid in selected: icon = "➕" - total_cost += price_per_month * months_multiplier + total_cost += discounted_price_per_month * months_multiplier else: icon = "⚪" if uuid not in current_subscription_countries and uuid in selected: - total_price = price_per_month * months_multiplier + total_price = discounted_price_per_month * months_multiplier if months_multiplier > 1: - price_text = f" ({price_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)" - logger.info(f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес = {total_price/100}₽") + price_text = ( + f" ({texts.format_price(discounted_price_per_month)} / мес × {months_multiplier}" + f" = {texts.format_price(total_price)})" + ) + logger.info( + "🔍 Сервер %s: %s/мес × %s мес = %s (скидка %s%%)", + name, + texts.format_price(discounted_price_per_month), + months_multiplier, + texts.format_price(total_price), + discount_percent, + ) else: - price_text = f" ({total_price//100}₽)" + price_text = f" ({texts.format_price(total_price)})" display_name = f"{icon} {name}{price_text}" else: display_name = f"{icon} {name}" - + buttons.append([ InlineKeyboardButton( text=display_name, callback_data=f"country_manage_{uuid}" ) ]) - + if total_cost > 0: - apply_text = f"✅ Применить изменения ({total_cost//100} ₽)" - logger.info(f"🔍 Общая стоимость новых серверов: {total_cost/100}₽") + apply_text = f"✅ Применить изменения ({texts.format_price(total_cost)})" + logger.info("🔍 Общая стоимость новых серверов: %s", texts.format_price(total_cost)) else: apply_text = "✅ Применить изменения" diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 54ba6720..8460b886 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -39,7 +39,7 @@ def _resolve_discount_percent( return 0 -def _resolve_addon_discount_percent( +def resolve_addon_discount_percent( user: Optional[User], promo_group: Optional[PromoGroup], category: str, @@ -878,7 +878,7 @@ class SubscriptionService: if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_discount_percent = _resolve_addon_discount_percent( + traffic_discount_percent = resolve_addon_discount_percent( user, promo_group, "traffic", @@ -901,7 +901,7 @@ class SubscriptionService: if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _resolve_addon_discount_percent( + devices_discount_percent = resolve_addon_discount_percent( user, promo_group, "devices", @@ -928,7 +928,7 @@ class SubscriptionService: server = await get_server_squad_by_id(db, server_id) if server and server.is_available: server_price_per_month = server.price_kopeks - servers_discount_percent = _resolve_addon_discount_percent( + servers_discount_percent = resolve_addon_discount_percent( user, promo_group, "servers", From f0b70c94248565e0e6705a99b52c6514a64aaa92 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 13:55:06 +0300 Subject: [PATCH 123/146] Revert "Fix addon discount calculations for extra services" --- app/handlers/subscription.py | 330 ++++++--------------------- app/keyboards/inline.py | 34 +-- app/services/subscription_service.py | 8 +- 3 files changed, 88 insertions(+), 284 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index c73261f5..97b91d0b 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -6,7 +6,7 @@ from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession import json import os -from typing import Dict, List, Any, Tuple, Optional, Set +from typing import Dict, List, Any, Tuple, Optional from app.config import settings, PERIOD_PRICES, get_traffic_prices from app.states import SubscriptionStates @@ -49,7 +49,7 @@ from app.keyboards.inline import ( from app.localization.texts import get_texts from app.services.remnawave_service import RemnaWaveService from app.services.admin_notification_service import AdminNotificationService -from app.services.subscription_service import SubscriptionService, resolve_addon_discount_percent +from app.services.subscription_service import SubscriptionService from app.services.subscription_checkout_service import ( clear_subscription_checkout_draft, get_subscription_checkout_draft, @@ -62,7 +62,6 @@ from app.utils.pricing_utils import ( calculate_prorated_price, validate_pricing_calculation, format_period_description, - apply_percentage_discount, ) from app.utils.pagination import paginate_list from app.utils.subscription_utils import ( @@ -1138,22 +1137,14 @@ async def handle_add_countries( text += "⚪ - не выбрана\n\n" text += "⚠️ Важно: Повторное подключение отключенных стран будет платным!" - ( - countries_with_pricing, - _country_map, - _available_country_ids, - _servers_discount_percent, - _months_to_pay, - ) = _prepare_countries_with_addon_pricing(db_user, countries) - await state.update_data(countries=current_countries.copy()) - + await callback.message.edit_text( text, reply_markup=get_manage_countries_keyboard( - countries_with_pricing, - current_countries.copy(), - current_countries, + countries, + current_countries.copy(), + current_countries, db_user.language, subscription.end_date ), @@ -1217,19 +1208,10 @@ async def handle_manage_country( return data = await state.get_data() - current_countries = subscription.connected_squads - current_selected = data.get('countries', current_countries.copy()) + current_selected = data.get('countries', subscription.connected_squads.copy()) countries = await _get_available_countries(db_user.promo_group_id) - ( - countries_with_pricing, - _country_map, - available_country_ids, - _servers_discount_percent, - _months_to_pay, - ) = _prepare_countries_with_addon_pricing(db_user, countries) - - allowed_country_ids = available_country_ids | set(current_countries) + allowed_country_ids = {country['uuid'] for country in countries} if country_uuid not in allowed_country_ids and country_uuid not in current_selected: await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) @@ -1249,11 +1231,11 @@ async def handle_manage_country( try: await callback.message.edit_reply_markup( reply_markup=get_manage_countries_keyboard( - countries_with_pricing, - current_selected, - current_countries, + countries, + current_selected, + subscription.connected_squads, db_user.language, - subscription.end_date + subscription.end_date ) ) logger.info(f"✅ Клавиатура обновлена") @@ -1288,15 +1270,7 @@ async def apply_countries_changes( current_countries = subscription.connected_squads countries = await _get_available_countries(db_user.promo_group_id) - ( - countries_with_pricing, - _country_map, - available_country_ids, - servers_discount_percent, - months_to_pay, - ) = _prepare_countries_with_addon_pricing(db_user, countries) - - allowed_country_ids = available_country_ids | set(current_countries) + allowed_country_ids = {country['uuid'] for country in countries} selected_countries = [ country_uuid @@ -1304,10 +1278,7 @@ async def apply_countries_changes( if country_uuid in allowed_country_ids or country_uuid in current_countries ] - added = [ - c for c in selected_countries - if c not in current_countries and c in available_country_ids - ] + added = [c for c in selected_countries if c not in current_countries] removed = [c for c in current_countries if c not in selected_countries] if not added and not removed: @@ -1316,37 +1287,31 @@ async def apply_countries_changes( logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") + months_to_pay = get_remaining_months(subscription.end_date) + cost_per_month = 0 added_names = [] removed_names = [] - + added_server_prices = [] - - for country in countries_with_pricing: + + for country in countries: if country['uuid'] in added: - server_price_per_month = country.get('discounted_price_kopeks', country['price_kopeks']) + server_price_per_month = country['price_kopeks'] cost_per_month += server_price_per_month added_names.append(country['name']) if country['uuid'] in removed: removed_names.append(country['name']) - - charged_months = months_to_pay - total_cost = cost_per_month * charged_months - - for country in countries_with_pricing: + + total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) + + for country in countries: if country['uuid'] in added: - server_price_per_month = country.get('discounted_price_kopeks', country['price_kopeks']) + server_price_per_month = country['price_kopeks'] server_total_price = server_price_per_month * charged_months added_server_prices.append(server_total_price) - - if cost_per_month > 0: - logger.info( - "Стоимость новых серверов: %s₽/мес × %s мес = %s₽ (скидка %s%%)", - cost_per_month / 100, - charged_months, - total_cost / 100, - servers_discount_percent, - ) + + logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽") if total_cost > 0 and db_user.balance_kopeks < total_cost: missing_kopeks = total_cost - db_user.balance_kopeks @@ -1381,7 +1346,7 @@ async def apply_countries_changes( try: if added and total_cost > 0: success = await subtract_user_balance( - db, db_user, total_cost, + db, db_user, total_cost, f"Добавление стран: {', '.join(added_names)} на {charged_months} мес" ) if not success: @@ -1559,22 +1524,8 @@ async def confirm_change_devices( chargeable_devices = additional_devices devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE - end_date = getattr(subscription, "end_date", None) - months_to_pay = get_remaining_months(end_date) if end_date else 1 - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None - devices_discount_percent = resolve_addon_discount_percent( - db_user, - getattr(db_user, "promo_group", None), - "devices", - period_days=period_hint_days, - ) - discounted_devices_price_per_month, discount_per_month = apply_percentage_discount( - devices_price_per_month, - devices_discount_percent, - ) - price = discounted_devices_price_per_month * months_to_pay - charged_months = months_to_pay - + price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) + if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" @@ -2188,32 +2139,10 @@ async def confirm_add_devices( return devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE - end_date = getattr(subscription, "end_date", None) - months_to_pay = get_remaining_months(end_date) if end_date else 1 - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None - devices_discount_percent = resolve_addon_discount_percent( - db_user, - getattr(db_user, "promo_group", None), - "devices", - period_days=period_hint_days, - ) - discounted_devices_price_per_month, discount_per_month = apply_percentage_discount( - devices_price_per_month, - devices_discount_percent, - ) - price = discounted_devices_price_per_month * months_to_pay - charged_months = months_to_pay - - logger.info( - "Добавление %s устройств: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽/мес)", - devices_count, - devices_price_per_month / 100, - charged_months, - price / 100, - devices_discount_percent, - discount_per_month / 100, - ) - + price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) + + logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽") + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" @@ -3572,29 +3501,14 @@ async def add_traffic( texts = get_texts(db_user.language) subscription = db_user.subscription - price_per_month = settings.get_traffic_price(traffic_gb) - - if price_per_month == 0 and traffic_gb != 0: + price = settings.get_traffic_price(traffic_gb) + + if price == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) return - - end_date = getattr(subscription, "end_date", None) - months_to_pay = get_remaining_months(end_date) if end_date else 1 - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None - traffic_discount_percent = resolve_addon_discount_percent( - db_user, - getattr(db_user, "promo_group", None), - "traffic", - period_days=period_hint_days, - ) - discounted_price_per_month, discount_per_month = apply_percentage_discount( - price_per_month, - traffic_discount_percent, - ) - total_price = discounted_price_per_month * months_to_pay - - if db_user.balance_kopeks < total_price: - missing_kopeks = total_price - db_user.balance_kopeks + + if db_user.balance_kopeks < price: + missing_kopeks = price - db_user.balance_kopeks message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -3605,7 +3519,7 @@ async def add_traffic( "Выберите способ пополнения. Сумма подставится автоматически." ), ).format( - required=f"{texts.format_price(total_price)} (за {months_to_pay} мес)", + required=texts.format_price(price), balance=texts.format_price(db_user.balance_kopeks), missing=texts.format_price(missing_kopeks), ) @@ -3620,13 +3534,13 @@ async def add_traffic( ) await callback.answer() return - + try: success = await subtract_user_balance( - db, db_user, total_price, + db, db_user, price, f"Добавление {traffic_gb} ГБ трафика" ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return @@ -3643,45 +3557,28 @@ async def add_traffic( db=db, user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=total_price, + amount_kopeks=price, description=f"Добавление {traffic_gb} ГБ трафика" ) - - + + await db.refresh(db_user) await db.refresh(subscription) - + success_text = f"✅ Трафик успешно добавлен!\n\n" if traffic_gb == 0: success_text += "🎉 Теперь у вас безлимитный трафик!" else: success_text += f"📈 Добавлено: {traffic_gb} ГБ\n" success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}" - if total_price > 0: - success_text += f"\n💰 Списано: {texts.format_price(total_price)} (за {months_to_pay} мес)" - if total_price > 0 and traffic_discount_percent > 0: - saved_total = discount_per_month * months_to_pay - if saved_total > 0: - success_text += ( - f"\n💸 Применена скидка {traffic_discount_percent}%" - f" (экономия {texts.format_price(saved_total)})" - ) - + await callback.message.edit_text( success_text, reply_markup=get_back_keyboard(db_user.language) ) - - logger.info( - "✅ Пользователь %s добавил %s ГБ трафика: %s₽/мес × %s мес = %s₽ (скидка %s%%)", - db_user.telegram_id, - traffic_gb, - price_per_month / 100, - months_to_pay, - total_price / 100, - traffic_discount_percent, - ) - + + logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика") + except Exception as e: logger.error(f"Ошибка добавления трафика: {e}") await callback.message.edit_text( @@ -3937,9 +3834,8 @@ async def _get_available_countries(promo_group_id: Optional[int] = None): countries = [] for server in available_servers: countries.append({ - "id": server.id, "uuid": server.squad_uuid, - "name": server.display_name, + "name": server.display_name, "price_kopeks": server.price_kopeks, "country_code": server.country_code, "is_available": server.is_available and not server.is_full @@ -3967,10 +3863,9 @@ async def _get_available_countries(promo_group_id: Optional[int] = None): squad_name = f"🌐 {squad_name}" countries.append({ - "id": None, "uuid": squad["uuid"], "name": squad_name, - "price_kopeks": 0, + "price_kopeks": 0, "is_available": True }) @@ -3990,50 +3885,6 @@ async def _get_countries_info(squad_uuids): countries = await _get_available_countries() return [c for c in countries if c['uuid'] in squad_uuids] - -def _prepare_countries_with_addon_pricing( - user: User, - countries: List[dict], -) -> Tuple[List[dict], Dict[str, dict], Set[str], int, int]: - subscription = user.subscription - end_date = getattr(subscription, "end_date", None) if subscription else None - months_to_pay = get_remaining_months(end_date) if end_date else 1 - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None - servers_discount_percent = resolve_addon_discount_percent( - user, - getattr(user, "promo_group", None), - "servers", - period_days=period_hint_days, - ) - - countries_with_pricing: List[dict] = [] - country_map: Dict[str, dict] = {} - available_country_ids: Set[str] = set() - - for country in countries: - country_copy = dict(country) - price_per_month = country_copy.get("price_kopeks", 0) - discounted_price, _ = apply_percentage_discount( - price_per_month, - servers_discount_percent, - ) - country_copy["discounted_price_kopeks"] = discounted_price - country_copy["discount_percent"] = servers_discount_percent - - if country_copy.get("is_available", True): - available_country_ids.add(country_copy["uuid"]) - - countries_with_pricing.append(country_copy) - country_map[country_copy["uuid"]] = country_copy - - return ( - countries_with_pricing, - country_map, - available_country_ids, - servers_discount_percent, - months_to_pay, - ) - async def handle_reset_devices( callback: types.CallbackQuery, db_user: User, @@ -4061,17 +3912,7 @@ async def handle_add_country_to_subscription( selected_countries = data.get('countries', []) countries = await _get_available_countries(db_user.promo_group_id) - - ( - countries_with_pricing, - country_map, - available_country_ids, - servers_discount_percent, - months_to_pay, - ) = _prepare_countries_with_addon_pricing(db_user, countries) - - subscription = db_user.subscription - allowed_country_ids = available_country_ids | set(subscription.connected_squads) + allowed_country_ids = {country['uuid'] for country in countries} if country_uuid not in allowed_country_ids and country_uuid not in selected_countries: await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) @@ -4085,31 +3926,21 @@ async def handle_add_country_to_subscription( logger.info(f"🔍 Добавлена страна: {country_uuid}") total_price = 0 - for uuid in selected_countries: - if uuid in country_map and uuid not in subscription.connected_squads and uuid in available_country_ids: - country_data = country_map[uuid] - discounted_price = country_data.get("discounted_price_kopeks", country_data.get("price_kopeks", 0)) - total_price += discounted_price * months_to_pay - + for country in countries: + if country['uuid'] in selected_countries and country['uuid'] not in db_user.subscription.connected_squads: + total_price += country['price_kopeks'] + data['countries'] = selected_countries data['total_price'] = total_price - data['servers_discount_percent'] = servers_discount_percent - data['months_to_pay'] = months_to_pay await state.set_data(data) - + logger.info(f"🔍 Новые выбранные страны: {selected_countries}") logger.info(f"🔍 Общая стоимость: {total_price}") - + try: from app.keyboards.inline import get_manage_countries_keyboard await callback.message.edit_reply_markup( - reply_markup=get_manage_countries_keyboard( - countries_with_pricing, - selected_countries, - db_user.subscription.connected_squads, - db_user.language, - subscription_end_date=subscription.end_date, - ) + reply_markup=get_manage_countries_keyboard(countries, selected_countries, db_user.subscription.connected_squads, db_user.language) ) logger.info(f"✅ Клавиатура обновлена") except Exception as e: @@ -4151,36 +3982,24 @@ async def confirm_add_countries_to_subscription( if country_uuid in allowed_country_ids or country_uuid in current_countries ] - new_countries = [ - c for c in selected_countries - if c not in current_countries and c in available_country_ids - ] + new_countries = [c for c in selected_countries if c not in current_countries] removed_countries = [c for c in current_countries if c not in selected_countries] - + if not new_countries and not removed_countries: await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) return - + total_price = 0 new_countries_names = [] removed_countries_names = [] - - for uuid in new_countries: - country = country_map.get(uuid) - if not country: - continue - discounted_price = country.get( - 'discounted_price_kopeks', - country.get('price_kopeks', 0), - ) - total_price += discounted_price * months_to_pay - new_countries_names.append(country['name']) - - for uuid in removed_countries: - country = country_map.get(uuid) - if country: + + for country in countries: + if country['uuid'] in new_countries: + total_price += country['price_kopeks'] + new_countries_names.append(country['name']) + if country['uuid'] in removed_countries: removed_countries_names.append(country['name']) - + if new_countries and db_user.balance_kopeks < total_price: missing_kopeks = total_price - db_user.balance_kopeks message_text = texts.t( @@ -4244,10 +4063,7 @@ async def confirm_add_countries_to_subscription( if new_countries_names: success_text += f"➕ Добавлены страны:\n{chr(10).join(f'• {name}' for name in new_countries_names)}\n" if total_price > 0: - success_text += ( - f"💰 Списано: {texts.format_price(total_price)}" - f" (за {months_to_pay} мес)\n" - ) + success_text += f"💰 Списано: {texts.format_price(total_price)}\n" if removed_countries_names: success_text += f"\n➖ Отключены страны:\n{chr(10).join(f'• {name}' for name in removed_countries_names)}\n" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 80bbf89d..adb4462f 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1313,10 +1313,8 @@ def get_manage_countries_keyboard( for country in countries: uuid = country['uuid'] name = country['name'] - price_per_month = country.get('price_kopeks', 0) - discounted_price_per_month = country.get('discounted_price_kopeks', price_per_month) - discount_percent = country.get('discount_percent', 0) - + price_per_month = country['price_kopeks'] + if uuid in current_subscription_countries: if uuid in selected: icon = "✅" @@ -1325,41 +1323,31 @@ def get_manage_countries_keyboard( else: if uuid in selected: icon = "➕" - total_cost += discounted_price_per_month * months_multiplier + total_cost += price_per_month * months_multiplier else: icon = "⚪" if uuid not in current_subscription_countries and uuid in selected: - total_price = discounted_price_per_month * months_multiplier + total_price = price_per_month * months_multiplier if months_multiplier > 1: - price_text = ( - f" ({texts.format_price(discounted_price_per_month)} / мес × {months_multiplier}" - f" = {texts.format_price(total_price)})" - ) - logger.info( - "🔍 Сервер %s: %s/мес × %s мес = %s (скидка %s%%)", - name, - texts.format_price(discounted_price_per_month), - months_multiplier, - texts.format_price(total_price), - discount_percent, - ) + price_text = f" ({price_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)" + logger.info(f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес = {total_price/100}₽") else: - price_text = f" ({texts.format_price(total_price)})" + price_text = f" ({total_price//100}₽)" display_name = f"{icon} {name}{price_text}" else: display_name = f"{icon} {name}" - + buttons.append([ InlineKeyboardButton( text=display_name, callback_data=f"country_manage_{uuid}" ) ]) - + if total_cost > 0: - apply_text = f"✅ Применить изменения ({texts.format_price(total_cost)})" - logger.info("🔍 Общая стоимость новых серверов: %s", texts.format_price(total_cost)) + apply_text = f"✅ Применить изменения ({total_cost//100} ₽)" + logger.info(f"🔍 Общая стоимость новых серверов: {total_cost/100}₽") else: apply_text = "✅ Применить изменения" diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 8460b886..54ba6720 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -39,7 +39,7 @@ def _resolve_discount_percent( return 0 -def resolve_addon_discount_percent( +def _resolve_addon_discount_percent( user: Optional[User], promo_group: Optional[PromoGroup], category: str, @@ -878,7 +878,7 @@ class SubscriptionService: if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_discount_percent = resolve_addon_discount_percent( + traffic_discount_percent = _resolve_addon_discount_percent( user, promo_group, "traffic", @@ -901,7 +901,7 @@ class SubscriptionService: if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = resolve_addon_discount_percent( + devices_discount_percent = _resolve_addon_discount_percent( user, promo_group, "devices", @@ -928,7 +928,7 @@ class SubscriptionService: server = await get_server_squad_by_id(db, server_id) if server and server.is_available: server_price_per_month = server.price_kopeks - servers_discount_percent = resolve_addon_discount_percent( + servers_discount_percent = _resolve_addon_discount_percent( user, promo_group, "servers", From 6f157e29236f202ca4012a045e9a296b2599a854 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 14:00:41 +0300 Subject: [PATCH 124/146] Fix addon discount application and cleanup formatting --- app/database/crud/subscription.py | 29 +-- app/handlers/subscription.py | 300 +++++++++++++++++++++------ app/keyboards/inline.py | 210 +++++++++++++------ app/services/subscription_service.py | 83 ++------ app/utils/pricing_utils.py | 45 +++- 5 files changed, 458 insertions(+), 209 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 91b79375..909cb321 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -13,7 +13,12 @@ from app.database.models import ( PromoGroup, ) from app.database.crud.notification import clear_notifications -from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months +from app.utils.pricing_utils import ( + calculate_months_from_days, + get_remaining_months, + resolve_discount_percent, + resolve_addon_discount_percent, +) from app.config import settings logger = logging.getLogger(__name__) @@ -505,16 +510,12 @@ def _get_discount_percent( *, period_days: Optional[int] = None, ) -> int: - if user is not None: - try: - return user.get_promo_discount(category, period_days) - except AttributeError: - pass - - if promo_group is not None: - return promo_group.get_discount_percent(category, period_days) - - return 0 + return resolve_discount_percent( + user, + promo_group, + category, + period_days=period_days, + ) async def calculate_subscription_total_cost( @@ -847,7 +848,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_discount_percent = _get_discount_percent( + traffic_discount_percent = resolve_addon_discount_percent( user, promo_group, "traffic", @@ -868,7 +869,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _get_discount_percent( + devices_discount_percent = resolve_addon_discount_percent( user, promo_group, "devices", @@ -897,7 +898,7 @@ async def calculate_addon_cost_for_remaining_period( server_data = result.first() if server_data: server_price_per_month, server_name = server_data - servers_discount_percent = _get_discount_percent( + servers_discount_percent = resolve_addon_discount_percent( user, promo_group, "servers", diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 97b91d0b..4ff58e9b 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -62,6 +62,7 @@ from app.utils.pricing_utils import ( calculate_prorated_price, validate_pricing_calculation, format_period_description, + resolve_addon_discount_percent, ) from app.utils.pagination import paginate_list from app.utils.subscription_utils import ( @@ -1142,11 +1143,12 @@ async def handle_add_countries( await callback.message.edit_text( text, reply_markup=get_manage_countries_keyboard( - countries, - current_countries.copy(), - current_countries, + countries, + current_countries.copy(), + current_countries, db_user.language, - subscription.end_date + subscription.end_date, + db_user, ), parse_mode="HTML" ) @@ -1211,7 +1213,11 @@ async def handle_manage_country( current_selected = data.get('countries', subscription.connected_squads.copy()) countries = await _get_available_countries(db_user.promo_group_id) - allowed_country_ids = {country['uuid'] for country in countries} + allowed_country_ids = { + country['uuid'] + for country in countries + if country.get('is_available', True) + } if country_uuid not in allowed_country_ids and country_uuid not in current_selected: await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) @@ -1232,10 +1238,11 @@ async def handle_manage_country( await callback.message.edit_reply_markup( reply_markup=get_manage_countries_keyboard( countries, - current_selected, - subscription.connected_squads, + current_selected, + subscription.connected_squads, db_user.language, - subscription.end_date + subscription.end_date, + db_user, ) ) logger.info(f"✅ Клавиатура обновлена") @@ -1270,12 +1277,16 @@ async def apply_countries_changes( current_countries = subscription.connected_squads countries = await _get_available_countries(db_user.promo_group_id) - allowed_country_ids = {country['uuid'] for country in countries} + available_country_ids = { + country['uuid'] + for country in countries + if country.get('is_available', True) + } selected_countries = [ country_uuid for country_uuid in selected_countries - if country_uuid in allowed_country_ids or country_uuid in current_countries + if country_uuid in available_country_ids or country_uuid in current_countries ] added = [c for c in selected_countries if c not in current_countries] @@ -1288,34 +1299,65 @@ async def apply_countries_changes( logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") months_to_pay = get_remaining_months(subscription.end_date) - - cost_per_month = 0 + period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + + servers_discount_percent = resolve_addon_discount_percent( + db_user, + db_user.promo_group, + "servers", + period_days=period_hint_days, + ) + + original_monthly_total = 0 added_names = [] removed_names = [] - + added_server_prices = [] - + total_discount = 0 + for country in countries: if country['uuid'] in added: server_price_per_month = country['price_kopeks'] - cost_per_month += server_price_per_month + original_monthly_total += server_price_per_month + component = _apply_discount_to_monthly_component( + server_price_per_month, + servers_discount_percent, + months_to_pay, + ) + added_server_prices.append(component["total"]) + total_discount += component["discount_total"] added_names.append(country['name']) if country['uuid'] in removed: removed_names.append(country['name']) - - total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) - - for country in countries: - if country['uuid'] in added: - server_price_per_month = country['price_kopeks'] - server_total_price = server_price_per_month * charged_months - added_server_prices.append(server_total_price) - - logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽") + + charged_months = months_to_pay + total_cost = sum(added_server_prices) + + logger.info( + "Стоимость новых серверов: %s₽/мес × %s мес = %s₽%s", + original_monthly_total / 100, + charged_months, + total_cost / 100, + ( + f" (скидка {servers_discount_percent}%: -{total_discount/100}₽)" + if servers_discount_percent > 0 and total_discount > 0 + else "" + ), + ) if total_cost > 0 and db_user.balance_kopeks < total_cost: missing_kopeks = total_cost - db_user.balance_kopeks - required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)" + required_text_parts = [texts.format_price(total_cost)] + details: List[str] = [] + if charged_months: + details.append(f"за {charged_months} мес") + if servers_discount_percent > 0 and total_discount > 0: + details.append( + f"скидка {servers_discount_percent}% (-{texts.format_price(total_discount)})" + ) + required_text = required_text_parts[0] + if details: + required_text += f" ({', '.join(details)})" message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -1346,7 +1388,7 @@ async def apply_countries_changes( try: if added and total_cost > 0: success = await subtract_user_balance( - db, db_user, total_cost, + db, db_user, total_cost, f"Добавление стран: {', '.join(added_names)} на {charged_months} мес" ) if not success: @@ -1371,7 +1413,11 @@ async def apply_countries_changes( await add_subscription_servers(db, subscription, added_server_ids, added_server_prices) await add_user_to_servers(db, added_server_ids) - logger.info(f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}") + logger.info( + "📊 Добавлены серверы с ценами за %s мес: %s", + charged_months, + list(zip(added_server_ids, added_server_prices)), + ) subscription.connected_squads = selected_countries subscription.updated_at = datetime.utcnow() @@ -1454,7 +1500,11 @@ async def handle_add_traffic( f"📈 Добавить трафик к подписке\n\n" f"Текущий лимит: {texts.format_traffic(current_traffic)}\n" f"Выберите дополнительный трафик:", - reply_markup=get_add_traffic_keyboard(db_user.language, subscription.end_date), + reply_markup=get_add_traffic_keyboard( + db_user.language, + subscription.end_date, + db_user, + ), parse_mode="HTML" ) @@ -1482,7 +1532,12 @@ async def handle_change_devices( f"💡 Важно:\n" f"• При увеличении - доплата пропорционально оставшемуся времени\n" f"• При уменьшении - возврат средств не производится", - reply_markup=get_change_devices_keyboard(current_devices, db_user.language, subscription.end_date), + reply_markup=get_change_devices_keyboard( + current_devices, + db_user.language, + subscription.end_date, + db_user, + ), parse_mode="HTML" ) @@ -1493,8 +1548,6 @@ async def confirm_change_devices( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price - new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -1524,11 +1577,35 @@ async def confirm_change_devices( chargeable_devices = additional_devices devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE - price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) - + months_to_pay = get_remaining_months(subscription.end_date) + period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + devices_discount_percent = resolve_addon_discount_percent( + db_user, + db_user.promo_group, + "devices", + period_days=period_hint_days, + ) + discount_component = _apply_discount_to_monthly_component( + devices_price_per_month, + devices_discount_percent, + months_to_pay, + ) + price = discount_component["total"] + charged_months = months_to_pay + devices_discount_total = discount_component["discount_total"] + if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks - required_text = f"{texts.format_price(price)} (за {charged_months} мес)" + details: List[str] = [] + if charged_months: + details.append(f"за {charged_months} мес") + if devices_discount_percent > 0 and devices_discount_total > 0: + details.append( + f"скидка {devices_discount_percent}% (-{texts.format_price(devices_discount_total)})" + ) + required_text = texts.format_price(price) + if details: + required_text += f" ({', '.join(details)})" message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -1556,7 +1633,16 @@ async def confirm_change_devices( return action_text = f"увеличить до {new_devices_count}" - cost_text = f"Доплата: {texts.format_price(price)} (за {charged_months} мес)" if price > 0 else "Бесплатно" + if price > 0: + cost_details = [f"за {charged_months} мес"] if charged_months else [] + if devices_discount_percent > 0 and devices_discount_total > 0: + cost_details.append( + f"скидка {devices_discount_percent}% (-{texts.format_price(devices_discount_total)})" + ) + suffix = f" ({', '.join(cost_details)})" if cost_details else "" + cost_text = f"Доплата: {texts.format_price(price)}{suffix}" + else: + cost_text = "Бесплатно" else: price = 0 @@ -2120,8 +2206,6 @@ async def confirm_add_devices( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price - devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -2139,13 +2223,48 @@ async def confirm_add_devices( return devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE - price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) - - logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽") - + months_to_pay = get_remaining_months(subscription.end_date) + period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + devices_discount_percent = resolve_addon_discount_percent( + db_user, + db_user.promo_group, + "devices", + period_days=period_hint_days, + ) + discount_component = _apply_discount_to_monthly_component( + devices_price_per_month, + devices_discount_percent, + months_to_pay, + ) + price = discount_component["total"] + charged_months = months_to_pay + devices_discount_total = discount_component["discount_total"] + + logger.info( + "Добавление %s устройств: %s₽/мес × %s мес = %s₽%s", + devices_count, + devices_price_per_month / 100, + charged_months, + price / 100, + ( + f" (скидка {devices_discount_percent}%: -{devices_discount_total/100}₽)" + if devices_discount_percent > 0 and devices_discount_total > 0 + else "", + ), + ) + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks - required_text = f"{texts.format_price(price)} (за {charged_months} мес)" + details: List[str] = [] + if charged_months: + details.append(f"за {charged_months} мес") + if devices_discount_percent > 0 and devices_discount_total > 0: + details.append( + f"скидка {devices_discount_percent}% (-{texts.format_price(devices_discount_total)})" + ) + required_text = texts.format_price(price) + if details: + required_text += f" ({', '.join(details)})" message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -3496,30 +3615,70 @@ async def add_traffic( if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return - + traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription - - price = settings.get_traffic_price(traffic_gb) - + + base_price = settings.get_traffic_price(traffic_gb) + months_to_pay = get_remaining_months(subscription.end_date) if subscription else 1 + period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + traffic_discount_percent = resolve_addon_discount_percent( + db_user, + db_user.promo_group, + "traffic", + period_days=period_hint_days, + ) + discount_component = _apply_discount_to_monthly_component( + base_price, + traffic_discount_percent, + 1, + ) + price = discount_component["total"] + traffic_discount_total = discount_component["discount_total"] + if price == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) return - + + logger.info( + "Добавление трафика +%s ГБ: %s₽ → %s₽%s", + traffic_gb, + base_price / 100, + price / 100, + ( + f" (скидка {traffic_discount_percent}%: -{traffic_discount_total/100}₽)" + if traffic_discount_percent > 0 and traffic_discount_total > 0 + else "", + ), + ) + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( - "⚠️ Недостаточно средств\n\n" - "Стоимость услуги: {required}\n" - "На балансе: {balance}\n" - "Не хватает: {missing}\n\n" + "⚠️ Недостаточно средств + +", + "Стоимость услуги: {required} +", + "На балансе: {balance} +", + "Не хватает: {missing} + +", "Выберите способ пополнения. Сумма подставится автоматически." ), ).format( - required=texts.format_price(price), + required=( + f"{texts.format_price(price)}" + + ( + f" (скидка {traffic_discount_percent}% (-{texts.format_price(traffic_discount_total)}))" + if traffic_discount_percent > 0 and traffic_discount_total > 0 + else "" + ) + ), balance=texts.format_price(db_user.balance_kopeks), missing=texts.format_price(missing_kopeks), ) @@ -3534,25 +3693,25 @@ async def add_traffic( ) await callback.answer() return - + try: success = await subtract_user_balance( db, db_user, price, f"Добавление {traffic_gb} ГБ трафика" ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - - if traffic_gb == 0: + + if traffic_gb == 0: subscription.traffic_limit_gb = 0 else: await add_subscription_traffic(db, subscription, traffic_gb) - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + await create_transaction( db=db, user_id=db_user.id, @@ -3560,34 +3719,34 @@ async def add_traffic( amount_kopeks=price, description=f"Добавление {traffic_gb} ГБ трафика" ) - - + await db.refresh(db_user) await db.refresh(subscription) - + success_text = f"✅ Трафик успешно добавлен!\n\n" if traffic_gb == 0: success_text += "🎉 Теперь у вас безлимитный трафик!" else: success_text += f"📈 Добавлено: {traffic_gb} ГБ\n" success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}" - + await callback.message.edit_text( success_text, reply_markup=get_back_keyboard(db_user.language) ) - + logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика") - + except Exception as e: logger.error(f"Ошибка добавления трафика: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() + async def create_paid_subscription_with_traffic_mode( db: AsyncSession, user_id: int, @@ -3940,7 +4099,14 @@ async def handle_add_country_to_subscription( try: from app.keyboards.inline import get_manage_countries_keyboard await callback.message.edit_reply_markup( - reply_markup=get_manage_countries_keyboard(countries, selected_countries, db_user.subscription.connected_squads, db_user.language) + reply_markup=get_manage_countries_keyboard( + countries, + selected_countries, + db_user.subscription.connected_squads, + db_user.language, + getattr(db_user.subscription, 'end_date', None), + db_user, + ) ) logger.info(f"✅ Клавиатура обновлена") except Exception as e: diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index adb4462f..634f485f 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -8,7 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts -from app.utils.pricing_utils import format_period_description +from app.utils.pricing_utils import ( + format_period_description, + resolve_addon_discount_percent, + apply_percentage_discount, + get_remaining_months, +) from app.utils.subscription_utils import ( get_display_subscription_link, get_happ_cryptolink_redirect_link, @@ -1123,21 +1128,25 @@ def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup: - from app.utils.pricing_utils import get_remaining_months +def get_add_traffic_keyboard( + language: str = DEFAULT_LANGUAGE, + subscription_end_date: datetime = None, + user: Optional[User] = None, +) -> InlineKeyboardMarkup: from app.config import settings + texts = get_texts(language) - + months_multiplier = 1 period_text = "" if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: period_text = f" (за {months_multiplier} мес)" - + packages = settings.get_traffic_packages() enabled_packages = [pkg for pkg in packages if pkg['enabled']] - + if not enabled_packages: return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton( @@ -1149,60 +1158,90 @@ def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_ callback_data="menu_subscription" )] ]) - + + discount_percent = 0 + if user: + period_hint_days = months_multiplier * 30 if months_multiplier > 0 else None + discount_percent = resolve_addon_discount_percent( + user, + getattr(user, "promo_group", None), + "traffic", + period_days=period_hint_days, + ) + buttons = [] - + for package in enabled_packages: gb = package['gb'] price_per_month = package['price'] - total_price = price_per_month * months_multiplier - + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + discount_percent, + ) + total_price = discounted_per_month * months_multiplier + total_discount = discount_per_month * months_multiplier + if gb == 0: - if language == "ru": - text = f"♾️ Безлимитный трафик - {total_price//100} ₽{period_text}" - else: - text = f"♾️ Unlimited traffic - {total_price//100} ₽{period_text}" + base_text = "♾️ Безлимитный трафик" else: - if language == "ru": - text = f"📊 +{gb} ГБ трафика - {total_price//100} ₽{period_text}" - else: - text = f"📊 +{gb} GB traffic - {total_price//100} ₽{period_text}" - + base_text = f"📊 +{gb} ГБ трафика" if language == "ru" else f"📊 +{gb} GB traffic" + + price_text = f" - {total_price//100} ₽{period_text}" + if discount_percent > 0 and total_discount > 0: + price_text += f" (скидка {discount_percent}%: -{total_discount//100} ₽)" + + text = base_text + price_text + buttons.append([ InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}") ]) - + buttons.append([ InlineKeyboardButton( text=texts.BACK, callback_data="menu_subscription" ) ]) - + return InlineKeyboardMarkup(inline_keyboard=buttons) -def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup: - from app.utils.pricing_utils import get_remaining_months +def get_change_devices_keyboard( + current_devices: int, + language: str = DEFAULT_LANGUAGE, + subscription_end_date: datetime = None, + user: Optional[User] = None, +) -> InlineKeyboardMarkup: from app.config import settings + texts = get_texts(language) - + months_multiplier = 1 period_text = "" if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: period_text = f" (за {months_multiplier} мес)" - + + discount_percent = 0 + if user: + period_hint_days = months_multiplier * 30 if months_multiplier > 0 else None + discount_percent = resolve_addon_discount_percent( + user, + getattr(user, "promo_group", None), + "devices", + period_days=period_hint_days, + ) + device_price_per_month = settings.PRICE_PER_DEVICE - + buttons = [] - - min_devices = 1 + + min_devices = 1 max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20 - + start_range = max(1, min(current_devices - 3, max_devices - 6)) end_range = min(max_devices + 1, max(current_devices + 4, 7)) - + for devices_count in range(start_range, end_range): if devices_count == current_devices: emoji = "✅" @@ -1211,15 +1250,23 @@ def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LA elif devices_count > current_devices: emoji = "➕" additional_devices = devices_count - current_devices - + current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT) new_chargeable = max(0, devices_count - settings.DEFAULT_DEVICE_LIMIT) chargeable_devices = new_chargeable - current_chargeable - + if chargeable_devices > 0: price_per_month = chargeable_devices * device_price_per_month - total_price = price_per_month * months_multiplier - price_text = f" (+{total_price//100}₽{period_text})" + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + discount_percent, + ) + total_price = discounted_per_month * months_multiplier + total_discount = discount_per_month * months_multiplier + price_text = f" (+{total_price//100}₽{period_text}" + if discount_percent > 0 and total_discount > 0: + price_text += f", скидка {discount_percent}% (-{total_discount//100}₽)" + price_text += ")" action_text = "" else: price_text = " (бесплатно)" @@ -1228,26 +1275,26 @@ def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LA emoji = "➖" action_text = "" price_text = " (без возврата)" - + button_text = f"{emoji} {devices_count} устр.{action_text}{price_text}" - + buttons.append([ InlineKeyboardButton(text=button_text, callback_data=f"change_devices_{devices_count}") ]) - + if current_devices < start_range or current_devices >= end_range: current_button = f"✅ {current_devices} устр. (текущее)" buttons.insert(0, [ InlineKeyboardButton(text=current_button, callback_data=f"change_devices_{current_devices}") ]) - + buttons.append([ InlineKeyboardButton( text=texts.BACK, callback_data="subscription_settings" ) ]) - + return InlineKeyboardMarkup(inline_keyboard=buttons) def get_confirm_change_devices_keyboard(new_devices_count: int, price: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: @@ -1296,72 +1343,103 @@ def get_manage_countries_keyboard( selected: List[str], current_subscription_countries: List[str], language: str = DEFAULT_LANGUAGE, - subscription_end_date: datetime = None + subscription_end_date: datetime = None, + user: Optional[User] = None, ) -> InlineKeyboardMarkup: - from app.utils.pricing_utils import get_remaining_months - texts = get_texts(language) months_multiplier = 1 if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) logger.info(f"🔍 Расчет для управления странами: осталось {months_multiplier} месяцев до {subscription_end_date}") - + + discount_percent = 0 + if user: + period_hint_days = months_multiplier * 30 if months_multiplier > 0 else None + discount_percent = resolve_addon_discount_percent( + user, + getattr(user, "promo_group", None), + "servers", + period_days=period_hint_days, + ) + buttons = [] total_cost = 0 - + for country in countries: uuid = country['uuid'] name = country['name'] price_per_month = country['price_kopeks'] - + is_available = country.get('is_available', True) + + if not is_available and uuid not in current_subscription_countries: + continue + if uuid in current_subscription_countries: - if uuid in selected: - icon = "✅" - else: - icon = "➖" + icon = "✅" if uuid in selected else "➖" else: - if uuid in selected: - icon = "➕" - total_cost += price_per_month * months_multiplier - else: - icon = "⚪" - + icon = "➕" if uuid in selected else "⚪" + + display_name = f"{icon} {name}" + if uuid not in current_subscription_countries and uuid in selected: - total_price = price_per_month * months_multiplier + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + discount_percent, + ) + total_price = discounted_per_month * months_multiplier + total_cost += total_price + if months_multiplier > 1: - price_text = f" ({price_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)" - logger.info(f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес = {total_price/100}₽") + price_text = ( + f" ({discounted_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽" + ) else: - price_text = f" ({total_price//100}₽)" + price_text = f" ({total_price//100}₽" + + total_discount = discount_per_month * months_multiplier + if discount_percent > 0 and total_discount > 0: + price_text += f", скидка {discount_percent}% (-{total_discount//100}₽)" + price_text += ")" + + logger.info( + f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес" + f" = {total_price/100}₽" + + ( + f" (скидка {discount_percent}%: -{total_discount/100}₽)" + if discount_percent > 0 and total_discount > 0 + else "" + ) + ) + display_name = f"{icon} {name}{price_text}" - else: - display_name = f"{icon} {name}" - + elif not is_available: + display_name = f"🚫 {name}" + buttons.append([ InlineKeyboardButton( text=display_name, callback_data=f"country_manage_{uuid}" ) ]) - + if total_cost > 0: apply_text = f"✅ Применить изменения ({total_cost//100} ₽)" logger.info(f"🔍 Общая стоимость новых серверов: {total_cost/100}₽") else: apply_text = "✅ Применить изменения" - + buttons.append([ InlineKeyboardButton(text=apply_text, callback_data="countries_apply") ]) - + buttons.append([ InlineKeyboardButton( text=texts.BACK, callback_data="menu_subscription" ) ]) - + return InlineKeyboardMarkup(inline_keyboard=buttons) def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 54ba6720..5d2f6b8d 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime, timedelta +from datetime import datetime from typing import Optional, List, Tuple from sqlalchemy.ext.asyncio import AsyncSession @@ -13,51 +13,14 @@ from app.database.crud.user import get_user_by_id from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, - calculate_prorated_price, - validate_pricing_calculation + validate_pricing_calculation, + resolve_discount_percent, + resolve_addon_discount_percent, ) logger = logging.getLogger(__name__) -def _resolve_discount_percent( - user: Optional[User], - promo_group: Optional[PromoGroup], - category: str, - *, - period_days: Optional[int] = None, -) -> int: - if user is not None: - try: - return user.get_promo_discount(category, period_days) - except AttributeError: - pass - - if promo_group is not None: - return promo_group.get_discount_percent(category, period_days) - - return 0 - - -def _resolve_addon_discount_percent( - user: Optional[User], - promo_group: Optional[PromoGroup], - category: str, - *, - period_days: Optional[int] = None, -) -> int: - group = promo_group or (getattr(user, "promo_group", None) if user else None) - - if group is not None and not getattr(group, "apply_discounts_to_addons", True): - return 0 - - return _resolve_discount_percent( - user, - promo_group, - category, - period_days=period_days, - ) - def get_traffic_reset_strategy(): from app.config import settings strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper() @@ -323,7 +286,7 @@ class SubscriptionService: raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}") base_price_original = PERIOD_PRICES.get(period_days, 0) - period_discount_percent = _resolve_discount_percent( + period_discount_percent = resolve_discount_percent( user, promo_group, "period", @@ -335,7 +298,7 @@ class SubscriptionService: promo_group = promo_group or (user.promo_group if user else None) traffic_price = settings.get_traffic_price(traffic_gb) - traffic_discount_percent = _resolve_discount_percent( + traffic_discount_percent = resolve_discount_percent( user, promo_group, "traffic", @@ -346,7 +309,7 @@ class SubscriptionService: server_prices = [] total_servers_price = 0 - servers_discount_percent = _resolve_discount_percent( + servers_discount_percent = resolve_discount_percent( user, promo_group, "servers", @@ -372,7 +335,7 @@ class SubscriptionService: logger.warning(f"Сервер ID {server_id} недоступен") devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE - devices_discount_percent = _resolve_discount_percent( + devices_discount_percent = resolve_discount_percent( user, promo_group, "devices", @@ -440,7 +403,7 @@ class SubscriptionService: promo_group_id=promo_group.id if promo_group else None, ) - servers_discount_percent = _resolve_discount_percent( + servers_discount_percent = resolve_discount_percent( user, promo_group, "servers", @@ -450,7 +413,7 @@ class SubscriptionService: discounted_servers_price = servers_price - servers_discount devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE - devices_discount_percent = _resolve_discount_percent( + devices_discount_percent = resolve_discount_percent( user, promo_group, "devices", @@ -460,7 +423,7 @@ class SubscriptionService: discounted_devices_price = devices_price - devices_discount traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb) - traffic_discount_percent = _resolve_discount_percent( + traffic_discount_percent = resolve_discount_percent( user, promo_group, "traffic", @@ -469,7 +432,7 @@ class SubscriptionService: traffic_discount = traffic_price * traffic_discount_percent // 100 discounted_traffic_price = traffic_price - traffic_discount - period_discount_percent = _resolve_discount_percent( + period_discount_percent = resolve_discount_percent( user, promo_group, "period", @@ -640,7 +603,7 @@ class SubscriptionService: months_in_period = calculate_months_from_days(period_days) base_price_original = PERIOD_PRICES.get(period_days, 0) - period_discount_percent = _resolve_discount_percent( + period_discount_percent = resolve_discount_percent( user, promo_group, "period", @@ -652,7 +615,7 @@ class SubscriptionService: promo_group = promo_group or (user.promo_group if user else None) traffic_price_per_month = settings.get_traffic_price(traffic_gb) - traffic_discount_percent = _resolve_discount_percent( + traffic_discount_percent = resolve_discount_percent( user, promo_group, "traffic", @@ -664,7 +627,7 @@ class SubscriptionService: server_prices = [] total_servers_price = 0 - servers_discount_percent = _resolve_discount_percent( + servers_discount_percent = resolve_discount_percent( user, promo_group, "servers", @@ -694,7 +657,7 @@ class SubscriptionService: additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _resolve_discount_percent( + devices_discount_percent = resolve_discount_percent( user, promo_group, "devices", @@ -768,7 +731,7 @@ class SubscriptionService: db, promo_group_id=promo_group.id if promo_group else None, ) - servers_discount_percent = _resolve_discount_percent( + servers_discount_percent = resolve_discount_percent( user, promo_group, "servers", @@ -780,7 +743,7 @@ class SubscriptionService: additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _resolve_discount_percent( + devices_discount_percent = resolve_discount_percent( user, promo_group, "devices", @@ -791,7 +754,7 @@ class SubscriptionService: total_devices_price = discounted_devices_per_month * months_in_period traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb) - traffic_discount_percent = _resolve_discount_percent( + traffic_discount_percent = resolve_discount_percent( user, promo_group, "traffic", @@ -801,7 +764,7 @@ class SubscriptionService: discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month total_traffic_price = discounted_traffic_per_month * months_in_period - period_discount_percent = _resolve_discount_percent( + period_discount_percent = resolve_discount_percent( user, promo_group, "period", @@ -878,7 +841,7 @@ class SubscriptionService: if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_discount_percent = _resolve_addon_discount_percent( + traffic_discount_percent = resolve_addon_discount_percent( user, promo_group, "traffic", @@ -901,7 +864,7 @@ class SubscriptionService: if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _resolve_addon_discount_percent( + devices_discount_percent = resolve_addon_discount_percent( user, promo_group, "devices", @@ -928,7 +891,7 @@ class SubscriptionService: server = await get_server_squad_by_id(db, server_id) if server and server.is_available: server_price_per_month = server.price_kopeks - servers_discount_percent = _resolve_addon_discount_percent( + servers_discount_percent = resolve_addon_discount_percent( user, promo_group, "servers", diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index 40d7f589..99c63950 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,7 +1,10 @@ -from datetime import datetime, timedelta -from typing import Tuple +from datetime import datetime +from typing import Tuple, Optional, TYPE_CHECKING import logging +if TYPE_CHECKING: + from app.database.models import User, PromoGroup + logger = logging.getLogger(__name__) @@ -115,3 +118,41 @@ STANDARD_PERIODS = { def get_period_info(days: int) -> dict: return STANDARD_PERIODS.get(days) + + +def resolve_discount_percent( + user: Optional["User"], + promo_group: Optional["PromoGroup"], + category: str, + *, + period_days: Optional[int] = None, +) -> int: + """Resolve the discount percent for the given category and optional period.""" + + if user is not None: + try: + return user.get_promo_discount(category, period_days) + except AttributeError: + pass + + if promo_group is not None: + return promo_group.get_discount_percent(category, period_days) + + return 0 + + +def resolve_addon_discount_percent( + user: Optional["User"], + promo_group: Optional["PromoGroup"], + category: str, + *, + period_days: Optional[int] = None, +) -> int: + """Resolve the discount percent for add-on purchases respecting promo settings.""" + + group = promo_group or (getattr(user, "promo_group", None) if user else None) + + if group is not None and not getattr(group, "apply_discounts_to_addons", True): + return 0 + + return resolve_discount_percent(user, promo_group, category, period_days=period_days) From 9ba183b6a6fd7c90540066d959dc9ce9c26bd54c Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 14:01:40 +0300 Subject: [PATCH 125/146] Revert "Fix add-on discount handling across subscription flows" --- app/database/crud/subscription.py | 29 ++- app/handlers/subscription.py | 300 ++++++--------------------- app/keyboards/inline.py | 216 ++++++------------- app/services/subscription_service.py | 83 ++++++-- app/utils/pricing_utils.py | 45 +--- 5 files changed, 212 insertions(+), 461 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 909cb321..91b79375 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -13,12 +13,7 @@ from app.database.models import ( PromoGroup, ) from app.database.crud.notification import clear_notifications -from app.utils.pricing_utils import ( - calculate_months_from_days, - get_remaining_months, - resolve_discount_percent, - resolve_addon_discount_percent, -) +from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months from app.config import settings logger = logging.getLogger(__name__) @@ -510,12 +505,16 @@ def _get_discount_percent( *, period_days: Optional[int] = None, ) -> int: - return resolve_discount_percent( - user, - promo_group, - category, - period_days=period_days, - ) + if user is not None: + try: + return user.get_promo_discount(category, period_days) + except AttributeError: + pass + + if promo_group is not None: + return promo_group.get_discount_percent(category, period_days) + + return 0 async def calculate_subscription_total_cost( @@ -848,7 +847,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_discount_percent = resolve_addon_discount_percent( + traffic_discount_percent = _get_discount_percent( user, promo_group, "traffic", @@ -869,7 +868,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = resolve_addon_discount_percent( + devices_discount_percent = _get_discount_percent( user, promo_group, "devices", @@ -898,7 +897,7 @@ async def calculate_addon_cost_for_remaining_period( server_data = result.first() if server_data: server_price_per_month, server_name = server_data - servers_discount_percent = resolve_addon_discount_percent( + servers_discount_percent = _get_discount_percent( user, promo_group, "servers", diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 4ff58e9b..97b91d0b 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -62,7 +62,6 @@ from app.utils.pricing_utils import ( calculate_prorated_price, validate_pricing_calculation, format_period_description, - resolve_addon_discount_percent, ) from app.utils.pagination import paginate_list from app.utils.subscription_utils import ( @@ -1143,12 +1142,11 @@ async def handle_add_countries( await callback.message.edit_text( text, reply_markup=get_manage_countries_keyboard( - countries, - current_countries.copy(), - current_countries, + countries, + current_countries.copy(), + current_countries, db_user.language, - subscription.end_date, - db_user, + subscription.end_date ), parse_mode="HTML" ) @@ -1213,11 +1211,7 @@ async def handle_manage_country( current_selected = data.get('countries', subscription.connected_squads.copy()) countries = await _get_available_countries(db_user.promo_group_id) - allowed_country_ids = { - country['uuid'] - for country in countries - if country.get('is_available', True) - } + allowed_country_ids = {country['uuid'] for country in countries} if country_uuid not in allowed_country_ids and country_uuid not in current_selected: await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) @@ -1238,11 +1232,10 @@ async def handle_manage_country( await callback.message.edit_reply_markup( reply_markup=get_manage_countries_keyboard( countries, - current_selected, - subscription.connected_squads, + current_selected, + subscription.connected_squads, db_user.language, - subscription.end_date, - db_user, + subscription.end_date ) ) logger.info(f"✅ Клавиатура обновлена") @@ -1277,16 +1270,12 @@ async def apply_countries_changes( current_countries = subscription.connected_squads countries = await _get_available_countries(db_user.promo_group_id) - available_country_ids = { - country['uuid'] - for country in countries - if country.get('is_available', True) - } + allowed_country_ids = {country['uuid'] for country in countries} selected_countries = [ country_uuid for country_uuid in selected_countries - if country_uuid in available_country_ids or country_uuid in current_countries + if country_uuid in allowed_country_ids or country_uuid in current_countries ] added = [c for c in selected_countries if c not in current_countries] @@ -1299,65 +1288,34 @@ async def apply_countries_changes( logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") months_to_pay = get_remaining_months(subscription.end_date) - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None - - servers_discount_percent = resolve_addon_discount_percent( - db_user, - db_user.promo_group, - "servers", - period_days=period_hint_days, - ) - - original_monthly_total = 0 + + cost_per_month = 0 added_names = [] removed_names = [] - + added_server_prices = [] - total_discount = 0 - + for country in countries: if country['uuid'] in added: server_price_per_month = country['price_kopeks'] - original_monthly_total += server_price_per_month - component = _apply_discount_to_monthly_component( - server_price_per_month, - servers_discount_percent, - months_to_pay, - ) - added_server_prices.append(component["total"]) - total_discount += component["discount_total"] + cost_per_month += server_price_per_month added_names.append(country['name']) if country['uuid'] in removed: removed_names.append(country['name']) - - charged_months = months_to_pay - total_cost = sum(added_server_prices) - - logger.info( - "Стоимость новых серверов: %s₽/мес × %s мес = %s₽%s", - original_monthly_total / 100, - charged_months, - total_cost / 100, - ( - f" (скидка {servers_discount_percent}%: -{total_discount/100}₽)" - if servers_discount_percent > 0 and total_discount > 0 - else "" - ), - ) + + total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) + + for country in countries: + if country['uuid'] in added: + server_price_per_month = country['price_kopeks'] + server_total_price = server_price_per_month * charged_months + added_server_prices.append(server_total_price) + + logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽") if total_cost > 0 and db_user.balance_kopeks < total_cost: missing_kopeks = total_cost - db_user.balance_kopeks - required_text_parts = [texts.format_price(total_cost)] - details: List[str] = [] - if charged_months: - details.append(f"за {charged_months} мес") - if servers_discount_percent > 0 and total_discount > 0: - details.append( - f"скидка {servers_discount_percent}% (-{texts.format_price(total_discount)})" - ) - required_text = required_text_parts[0] - if details: - required_text += f" ({', '.join(details)})" + required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)" message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -1388,7 +1346,7 @@ async def apply_countries_changes( try: if added and total_cost > 0: success = await subtract_user_balance( - db, db_user, total_cost, + db, db_user, total_cost, f"Добавление стран: {', '.join(added_names)} на {charged_months} мес" ) if not success: @@ -1413,11 +1371,7 @@ async def apply_countries_changes( await add_subscription_servers(db, subscription, added_server_ids, added_server_prices) await add_user_to_servers(db, added_server_ids) - logger.info( - "📊 Добавлены серверы с ценами за %s мес: %s", - charged_months, - list(zip(added_server_ids, added_server_prices)), - ) + logger.info(f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}") subscription.connected_squads = selected_countries subscription.updated_at = datetime.utcnow() @@ -1500,11 +1454,7 @@ async def handle_add_traffic( f"📈 Добавить трафик к подписке\n\n" f"Текущий лимит: {texts.format_traffic(current_traffic)}\n" f"Выберите дополнительный трафик:", - reply_markup=get_add_traffic_keyboard( - db_user.language, - subscription.end_date, - db_user, - ), + reply_markup=get_add_traffic_keyboard(db_user.language, subscription.end_date), parse_mode="HTML" ) @@ -1532,12 +1482,7 @@ async def handle_change_devices( f"💡 Важно:\n" f"• При увеличении - доплата пропорционально оставшемуся времени\n" f"• При уменьшении - возврат средств не производится", - reply_markup=get_change_devices_keyboard( - current_devices, - db_user.language, - subscription.end_date, - db_user, - ), + reply_markup=get_change_devices_keyboard(current_devices, db_user.language, subscription.end_date), parse_mode="HTML" ) @@ -1548,6 +1493,8 @@ async def confirm_change_devices( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price + new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -1577,35 +1524,11 @@ async def confirm_change_devices( chargeable_devices = additional_devices devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE - months_to_pay = get_remaining_months(subscription.end_date) - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None - devices_discount_percent = resolve_addon_discount_percent( - db_user, - db_user.promo_group, - "devices", - period_days=period_hint_days, - ) - discount_component = _apply_discount_to_monthly_component( - devices_price_per_month, - devices_discount_percent, - months_to_pay, - ) - price = discount_component["total"] - charged_months = months_to_pay - devices_discount_total = discount_component["discount_total"] - + price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) + if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks - details: List[str] = [] - if charged_months: - details.append(f"за {charged_months} мес") - if devices_discount_percent > 0 and devices_discount_total > 0: - details.append( - f"скидка {devices_discount_percent}% (-{texts.format_price(devices_discount_total)})" - ) - required_text = texts.format_price(price) - if details: - required_text += f" ({', '.join(details)})" + required_text = f"{texts.format_price(price)} (за {charged_months} мес)" message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -1633,16 +1556,7 @@ async def confirm_change_devices( return action_text = f"увеличить до {new_devices_count}" - if price > 0: - cost_details = [f"за {charged_months} мес"] if charged_months else [] - if devices_discount_percent > 0 and devices_discount_total > 0: - cost_details.append( - f"скидка {devices_discount_percent}% (-{texts.format_price(devices_discount_total)})" - ) - suffix = f" ({', '.join(cost_details)})" if cost_details else "" - cost_text = f"Доплата: {texts.format_price(price)}{suffix}" - else: - cost_text = "Бесплатно" + cost_text = f"Доплата: {texts.format_price(price)} (за {charged_months} мес)" if price > 0 else "Бесплатно" else: price = 0 @@ -2206,6 +2120,8 @@ async def confirm_add_devices( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price + devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -2223,48 +2139,13 @@ async def confirm_add_devices( return devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE - months_to_pay = get_remaining_months(subscription.end_date) - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None - devices_discount_percent = resolve_addon_discount_percent( - db_user, - db_user.promo_group, - "devices", - period_days=period_hint_days, - ) - discount_component = _apply_discount_to_monthly_component( - devices_price_per_month, - devices_discount_percent, - months_to_pay, - ) - price = discount_component["total"] - charged_months = months_to_pay - devices_discount_total = discount_component["discount_total"] - - logger.info( - "Добавление %s устройств: %s₽/мес × %s мес = %s₽%s", - devices_count, - devices_price_per_month / 100, - charged_months, - price / 100, - ( - f" (скидка {devices_discount_percent}%: -{devices_discount_total/100}₽)" - if devices_discount_percent > 0 and devices_discount_total > 0 - else "", - ), - ) - + price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) + + logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽") + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks - details: List[str] = [] - if charged_months: - details.append(f"за {charged_months} мес") - if devices_discount_percent > 0 and devices_discount_total > 0: - details.append( - f"скидка {devices_discount_percent}% (-{texts.format_price(devices_discount_total)})" - ) - required_text = texts.format_price(price) - if details: - required_text += f" ({', '.join(details)})" + required_text = f"{texts.format_price(price)} (за {charged_months} мес)" message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -3615,70 +3496,30 @@ async def add_traffic( if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return - + traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription - - base_price = settings.get_traffic_price(traffic_gb) - months_to_pay = get_remaining_months(subscription.end_date) if subscription else 1 - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None - traffic_discount_percent = resolve_addon_discount_percent( - db_user, - db_user.promo_group, - "traffic", - period_days=period_hint_days, - ) - discount_component = _apply_discount_to_monthly_component( - base_price, - traffic_discount_percent, - 1, - ) - price = discount_component["total"] - traffic_discount_total = discount_component["discount_total"] - + + price = settings.get_traffic_price(traffic_gb) + if price == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) return - - logger.info( - "Добавление трафика +%s ГБ: %s₽ → %s₽%s", - traffic_gb, - base_price / 100, - price / 100, - ( - f" (скидка {traffic_discount_percent}%: -{traffic_discount_total/100}₽)" - if traffic_discount_percent > 0 and traffic_discount_total > 0 - else "", - ), - ) - + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( - "⚠️ Недостаточно средств - -", - "Стоимость услуги: {required} -", - "На балансе: {balance} -", - "Не хватает: {missing} - -", + "⚠️ Недостаточно средств\n\n" + "Стоимость услуги: {required}\n" + "На балансе: {balance}\n" + "Не хватает: {missing}\n\n" "Выберите способ пополнения. Сумма подставится автоматически." ), ).format( - required=( - f"{texts.format_price(price)}" - + ( - f" (скидка {traffic_discount_percent}% (-{texts.format_price(traffic_discount_total)}))" - if traffic_discount_percent > 0 and traffic_discount_total > 0 - else "" - ) - ), + required=texts.format_price(price), balance=texts.format_price(db_user.balance_kopeks), missing=texts.format_price(missing_kopeks), ) @@ -3693,25 +3534,25 @@ async def add_traffic( ) await callback.answer() return - + try: success = await subtract_user_balance( db, db_user, price, f"Добавление {traffic_gb} ГБ трафика" ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - - if traffic_gb == 0: + + if traffic_gb == 0: subscription.traffic_limit_gb = 0 else: await add_subscription_traffic(db, subscription, traffic_gb) - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + await create_transaction( db=db, user_id=db_user.id, @@ -3719,34 +3560,34 @@ async def add_traffic( amount_kopeks=price, description=f"Добавление {traffic_gb} ГБ трафика" ) - + + await db.refresh(db_user) await db.refresh(subscription) - + success_text = f"✅ Трафик успешно добавлен!\n\n" if traffic_gb == 0: success_text += "🎉 Теперь у вас безлимитный трафик!" else: success_text += f"📈 Добавлено: {traffic_gb} ГБ\n" success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}" - + await callback.message.edit_text( success_text, reply_markup=get_back_keyboard(db_user.language) ) - + logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика") - + except Exception as e: logger.error(f"Ошибка добавления трафика: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() - async def create_paid_subscription_with_traffic_mode( db: AsyncSession, user_id: int, @@ -4099,14 +3940,7 @@ async def handle_add_country_to_subscription( try: from app.keyboards.inline import get_manage_countries_keyboard await callback.message.edit_reply_markup( - reply_markup=get_manage_countries_keyboard( - countries, - selected_countries, - db_user.subscription.connected_squads, - db_user.language, - getattr(db_user.subscription, 'end_date', None), - db_user, - ) + reply_markup=get_manage_countries_keyboard(countries, selected_countries, db_user.subscription.connected_squads, db_user.language) ) logger.info(f"✅ Клавиатура обновлена") except Exception as e: diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 634f485f..adb4462f 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -8,12 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts -from app.utils.pricing_utils import ( - format_period_description, - resolve_addon_discount_percent, - apply_percentage_discount, - get_remaining_months, -) +from app.utils.pricing_utils import format_period_description from app.utils.subscription_utils import ( get_display_subscription_link, get_happ_cryptolink_redirect_link, @@ -1128,25 +1123,21 @@ def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_add_traffic_keyboard( - language: str = DEFAULT_LANGUAGE, - subscription_end_date: datetime = None, - user: Optional[User] = None, -) -> InlineKeyboardMarkup: +def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup: + from app.utils.pricing_utils import get_remaining_months from app.config import settings - texts = get_texts(language) - + months_multiplier = 1 period_text = "" if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: period_text = f" (за {months_multiplier} мес)" - + packages = settings.get_traffic_packages() enabled_packages = [pkg for pkg in packages if pkg['enabled']] - + if not enabled_packages: return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton( @@ -1158,90 +1149,60 @@ def get_add_traffic_keyboard( callback_data="menu_subscription" )] ]) - - discount_percent = 0 - if user: - period_hint_days = months_multiplier * 30 if months_multiplier > 0 else None - discount_percent = resolve_addon_discount_percent( - user, - getattr(user, "promo_group", None), - "traffic", - period_days=period_hint_days, - ) - + buttons = [] - + for package in enabled_packages: gb = package['gb'] price_per_month = package['price'] - discounted_per_month, discount_per_month = apply_percentage_discount( - price_per_month, - discount_percent, - ) - total_price = discounted_per_month * months_multiplier - total_discount = discount_per_month * months_multiplier - + total_price = price_per_month * months_multiplier + if gb == 0: - base_text = "♾️ Безлимитный трафик" + if language == "ru": + text = f"♾️ Безлимитный трафик - {total_price//100} ₽{period_text}" + else: + text = f"♾️ Unlimited traffic - {total_price//100} ₽{period_text}" else: - base_text = f"📊 +{gb} ГБ трафика" if language == "ru" else f"📊 +{gb} GB traffic" - - price_text = f" - {total_price//100} ₽{period_text}" - if discount_percent > 0 and total_discount > 0: - price_text += f" (скидка {discount_percent}%: -{total_discount//100} ₽)" - - text = base_text + price_text - + if language == "ru": + text = f"📊 +{gb} ГБ трафика - {total_price//100} ₽{period_text}" + else: + text = f"📊 +{gb} GB traffic - {total_price//100} ₽{period_text}" + buttons.append([ InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}") ]) - + buttons.append([ InlineKeyboardButton( text=texts.BACK, callback_data="menu_subscription" ) ]) - + return InlineKeyboardMarkup(inline_keyboard=buttons) -def get_change_devices_keyboard( - current_devices: int, - language: str = DEFAULT_LANGUAGE, - subscription_end_date: datetime = None, - user: Optional[User] = None, -) -> InlineKeyboardMarkup: +def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup: + from app.utils.pricing_utils import get_remaining_months from app.config import settings - texts = get_texts(language) - + months_multiplier = 1 period_text = "" if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: period_text = f" (за {months_multiplier} мес)" - - discount_percent = 0 - if user: - period_hint_days = months_multiplier * 30 if months_multiplier > 0 else None - discount_percent = resolve_addon_discount_percent( - user, - getattr(user, "promo_group", None), - "devices", - period_days=period_hint_days, - ) - + device_price_per_month = settings.PRICE_PER_DEVICE - + buttons = [] - - min_devices = 1 + + min_devices = 1 max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20 - + start_range = max(1, min(current_devices - 3, max_devices - 6)) end_range = min(max_devices + 1, max(current_devices + 4, 7)) - + for devices_count in range(start_range, end_range): if devices_count == current_devices: emoji = "✅" @@ -1250,23 +1211,15 @@ def get_change_devices_keyboard( elif devices_count > current_devices: emoji = "➕" additional_devices = devices_count - current_devices - + current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT) new_chargeable = max(0, devices_count - settings.DEFAULT_DEVICE_LIMIT) chargeable_devices = new_chargeable - current_chargeable - + if chargeable_devices > 0: price_per_month = chargeable_devices * device_price_per_month - discounted_per_month, discount_per_month = apply_percentage_discount( - price_per_month, - discount_percent, - ) - total_price = discounted_per_month * months_multiplier - total_discount = discount_per_month * months_multiplier - price_text = f" (+{total_price//100}₽{period_text}" - if discount_percent > 0 and total_discount > 0: - price_text += f", скидка {discount_percent}% (-{total_discount//100}₽)" - price_text += ")" + total_price = price_per_month * months_multiplier + price_text = f" (+{total_price//100}₽{period_text})" action_text = "" else: price_text = " (бесплатно)" @@ -1275,26 +1228,26 @@ def get_change_devices_keyboard( emoji = "➖" action_text = "" price_text = " (без возврата)" - + button_text = f"{emoji} {devices_count} устр.{action_text}{price_text}" - + buttons.append([ InlineKeyboardButton(text=button_text, callback_data=f"change_devices_{devices_count}") ]) - + if current_devices < start_range or current_devices >= end_range: current_button = f"✅ {current_devices} устр. (текущее)" buttons.insert(0, [ InlineKeyboardButton(text=current_button, callback_data=f"change_devices_{current_devices}") ]) - + buttons.append([ InlineKeyboardButton( text=texts.BACK, callback_data="subscription_settings" ) ]) - + return InlineKeyboardMarkup(inline_keyboard=buttons) def get_confirm_change_devices_keyboard(new_devices_count: int, price: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: @@ -1343,103 +1296,72 @@ def get_manage_countries_keyboard( selected: List[str], current_subscription_countries: List[str], language: str = DEFAULT_LANGUAGE, - subscription_end_date: datetime = None, - user: Optional[User] = None, + subscription_end_date: datetime = None ) -> InlineKeyboardMarkup: + from app.utils.pricing_utils import get_remaining_months + texts = get_texts(language) months_multiplier = 1 if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) logger.info(f"🔍 Расчет для управления странами: осталось {months_multiplier} месяцев до {subscription_end_date}") - - discount_percent = 0 - if user: - period_hint_days = months_multiplier * 30 if months_multiplier > 0 else None - discount_percent = resolve_addon_discount_percent( - user, - getattr(user, "promo_group", None), - "servers", - period_days=period_hint_days, - ) - + buttons = [] total_cost = 0 - + for country in countries: uuid = country['uuid'] name = country['name'] price_per_month = country['price_kopeks'] - is_available = country.get('is_available', True) - - if not is_available and uuid not in current_subscription_countries: - continue - + if uuid in current_subscription_countries: - icon = "✅" if uuid in selected else "➖" - else: - icon = "➕" if uuid in selected else "⚪" - - display_name = f"{icon} {name}" - - if uuid not in current_subscription_countries and uuid in selected: - discounted_per_month, discount_per_month = apply_percentage_discount( - price_per_month, - discount_percent, - ) - total_price = discounted_per_month * months_multiplier - total_cost += total_price - - if months_multiplier > 1: - price_text = ( - f" ({discounted_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽" - ) + if uuid in selected: + icon = "✅" else: - price_text = f" ({total_price//100}₽" - - total_discount = discount_per_month * months_multiplier - if discount_percent > 0 and total_discount > 0: - price_text += f", скидка {discount_percent}% (-{total_discount//100}₽)" - price_text += ")" - - logger.info( - f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес" - f" = {total_price/100}₽" - + ( - f" (скидка {discount_percent}%: -{total_discount/100}₽)" - if discount_percent > 0 and total_discount > 0 - else "" - ) - ) - + icon = "➖" + else: + if uuid in selected: + icon = "➕" + total_cost += price_per_month * months_multiplier + else: + icon = "⚪" + + if uuid not in current_subscription_countries and uuid in selected: + total_price = price_per_month * months_multiplier + if months_multiplier > 1: + price_text = f" ({price_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)" + logger.info(f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес = {total_price/100}₽") + else: + price_text = f" ({total_price//100}₽)" display_name = f"{icon} {name}{price_text}" - elif not is_available: - display_name = f"🚫 {name}" - + else: + display_name = f"{icon} {name}" + buttons.append([ InlineKeyboardButton( text=display_name, callback_data=f"country_manage_{uuid}" ) ]) - + if total_cost > 0: apply_text = f"✅ Применить изменения ({total_cost//100} ₽)" logger.info(f"🔍 Общая стоимость новых серверов: {total_cost/100}₽") else: apply_text = "✅ Применить изменения" - + buttons.append([ InlineKeyboardButton(text=apply_text, callback_data="countries_apply") ]) - + buttons.append([ InlineKeyboardButton( text=texts.BACK, callback_data="menu_subscription" ) ]) - + return InlineKeyboardMarkup(inline_keyboard=buttons) def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 5d2f6b8d..54ba6720 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime +from datetime import datetime, timedelta from typing import Optional, List, Tuple from sqlalchemy.ext.asyncio import AsyncSession @@ -13,14 +13,51 @@ from app.database.crud.user import get_user_by_id from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, - validate_pricing_calculation, - resolve_discount_percent, - resolve_addon_discount_percent, + calculate_prorated_price, + validate_pricing_calculation ) logger = logging.getLogger(__name__) +def _resolve_discount_percent( + user: Optional[User], + promo_group: Optional[PromoGroup], + category: str, + *, + period_days: Optional[int] = None, +) -> int: + if user is not None: + try: + return user.get_promo_discount(category, period_days) + except AttributeError: + pass + + if promo_group is not None: + return promo_group.get_discount_percent(category, period_days) + + return 0 + + +def _resolve_addon_discount_percent( + user: Optional[User], + promo_group: Optional[PromoGroup], + category: str, + *, + period_days: Optional[int] = None, +) -> int: + group = promo_group or (getattr(user, "promo_group", None) if user else None) + + if group is not None and not getattr(group, "apply_discounts_to_addons", True): + return 0 + + return _resolve_discount_percent( + user, + promo_group, + category, + period_days=period_days, + ) + def get_traffic_reset_strategy(): from app.config import settings strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper() @@ -286,7 +323,7 @@ class SubscriptionService: raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}") base_price_original = PERIOD_PRICES.get(period_days, 0) - period_discount_percent = resolve_discount_percent( + period_discount_percent = _resolve_discount_percent( user, promo_group, "period", @@ -298,7 +335,7 @@ class SubscriptionService: promo_group = promo_group or (user.promo_group if user else None) traffic_price = settings.get_traffic_price(traffic_gb) - traffic_discount_percent = resolve_discount_percent( + traffic_discount_percent = _resolve_discount_percent( user, promo_group, "traffic", @@ -309,7 +346,7 @@ class SubscriptionService: server_prices = [] total_servers_price = 0 - servers_discount_percent = resolve_discount_percent( + servers_discount_percent = _resolve_discount_percent( user, promo_group, "servers", @@ -335,7 +372,7 @@ class SubscriptionService: logger.warning(f"Сервер ID {server_id} недоступен") devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE - devices_discount_percent = resolve_discount_percent( + devices_discount_percent = _resolve_discount_percent( user, promo_group, "devices", @@ -403,7 +440,7 @@ class SubscriptionService: promo_group_id=promo_group.id if promo_group else None, ) - servers_discount_percent = resolve_discount_percent( + servers_discount_percent = _resolve_discount_percent( user, promo_group, "servers", @@ -413,7 +450,7 @@ class SubscriptionService: discounted_servers_price = servers_price - servers_discount devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE - devices_discount_percent = resolve_discount_percent( + devices_discount_percent = _resolve_discount_percent( user, promo_group, "devices", @@ -423,7 +460,7 @@ class SubscriptionService: discounted_devices_price = devices_price - devices_discount traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb) - traffic_discount_percent = resolve_discount_percent( + traffic_discount_percent = _resolve_discount_percent( user, promo_group, "traffic", @@ -432,7 +469,7 @@ class SubscriptionService: traffic_discount = traffic_price * traffic_discount_percent // 100 discounted_traffic_price = traffic_price - traffic_discount - period_discount_percent = resolve_discount_percent( + period_discount_percent = _resolve_discount_percent( user, promo_group, "period", @@ -603,7 +640,7 @@ class SubscriptionService: months_in_period = calculate_months_from_days(period_days) base_price_original = PERIOD_PRICES.get(period_days, 0) - period_discount_percent = resolve_discount_percent( + period_discount_percent = _resolve_discount_percent( user, promo_group, "period", @@ -615,7 +652,7 @@ class SubscriptionService: promo_group = promo_group or (user.promo_group if user else None) traffic_price_per_month = settings.get_traffic_price(traffic_gb) - traffic_discount_percent = resolve_discount_percent( + traffic_discount_percent = _resolve_discount_percent( user, promo_group, "traffic", @@ -627,7 +664,7 @@ class SubscriptionService: server_prices = [] total_servers_price = 0 - servers_discount_percent = resolve_discount_percent( + servers_discount_percent = _resolve_discount_percent( user, promo_group, "servers", @@ -657,7 +694,7 @@ class SubscriptionService: additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = resolve_discount_percent( + devices_discount_percent = _resolve_discount_percent( user, promo_group, "devices", @@ -731,7 +768,7 @@ class SubscriptionService: db, promo_group_id=promo_group.id if promo_group else None, ) - servers_discount_percent = resolve_discount_percent( + servers_discount_percent = _resolve_discount_percent( user, promo_group, "servers", @@ -743,7 +780,7 @@ class SubscriptionService: additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = resolve_discount_percent( + devices_discount_percent = _resolve_discount_percent( user, promo_group, "devices", @@ -754,7 +791,7 @@ class SubscriptionService: total_devices_price = discounted_devices_per_month * months_in_period traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb) - traffic_discount_percent = resolve_discount_percent( + traffic_discount_percent = _resolve_discount_percent( user, promo_group, "traffic", @@ -764,7 +801,7 @@ class SubscriptionService: discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month total_traffic_price = discounted_traffic_per_month * months_in_period - period_discount_percent = resolve_discount_percent( + period_discount_percent = _resolve_discount_percent( user, promo_group, "period", @@ -841,7 +878,7 @@ class SubscriptionService: if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_discount_percent = resolve_addon_discount_percent( + traffic_discount_percent = _resolve_addon_discount_percent( user, promo_group, "traffic", @@ -864,7 +901,7 @@ class SubscriptionService: if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = resolve_addon_discount_percent( + devices_discount_percent = _resolve_addon_discount_percent( user, promo_group, "devices", @@ -891,7 +928,7 @@ class SubscriptionService: server = await get_server_squad_by_id(db, server_id) if server and server.is_available: server_price_per_month = server.price_kopeks - servers_discount_percent = resolve_addon_discount_percent( + servers_discount_percent = _resolve_addon_discount_percent( user, promo_group, "servers", diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index 99c63950..40d7f589 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,10 +1,7 @@ -from datetime import datetime -from typing import Tuple, Optional, TYPE_CHECKING +from datetime import datetime, timedelta +from typing import Tuple import logging -if TYPE_CHECKING: - from app.database.models import User, PromoGroup - logger = logging.getLogger(__name__) @@ -118,41 +115,3 @@ STANDARD_PERIODS = { def get_period_info(days: int) -> dict: return STANDARD_PERIODS.get(days) - - -def resolve_discount_percent( - user: Optional["User"], - promo_group: Optional["PromoGroup"], - category: str, - *, - period_days: Optional[int] = None, -) -> int: - """Resolve the discount percent for the given category and optional period.""" - - if user is not None: - try: - return user.get_promo_discount(category, period_days) - except AttributeError: - pass - - if promo_group is not None: - return promo_group.get_discount_percent(category, period_days) - - return 0 - - -def resolve_addon_discount_percent( - user: Optional["User"], - promo_group: Optional["PromoGroup"], - category: str, - *, - period_days: Optional[int] = None, -) -> int: - """Resolve the discount percent for add-on purchases respecting promo settings.""" - - group = promo_group or (getattr(user, "promo_group", None) if user else None) - - if group is not None and not getattr(group, "apply_discounts_to_addons", True): - return 0 - - return resolve_discount_percent(user, promo_group, category, period_days=period_days) From bfa355e956c9b8ce8fcabc77cafb3ab878a9d86e Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 14:05:55 +0300 Subject: [PATCH 126/146] Revert "Fix addon discounts for server/device/traffic purchases" --- app/database/crud/subscription.py | 42 +---- app/handlers/subscription.py | 241 ++++----------------------- app/services/subscription_service.py | 10 +- app/utils/pricing_utils.py | 32 +--- 4 files changed, 49 insertions(+), 276 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index cd8b5378..91b79375 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1,7 +1,7 @@ import logging from datetime import datetime, timedelta from typing import Optional, List, Tuple -from sqlalchemy import select, and_, func, or_ +from sqlalchemy import select, and_, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -13,11 +13,7 @@ from app.database.models import ( PromoGroup, ) from app.database.crud.notification import clear_notifications -from app.utils.pricing_utils import ( - calculate_months_from_days, - get_remaining_months, - resolve_addon_discount_percent, -) +from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months from app.config import settings logger = logging.getLogger(__name__) @@ -521,23 +517,6 @@ def _get_discount_percent( return 0 -def _get_addon_discount_percent( - user: Optional[User], - promo_group: Optional[PromoGroup], - category: str, - *, - period_days: Optional[int] = None, -) -> int: - group = promo_group or (getattr(user, "promo_group", None) if user else None) - - return resolve_addon_discount_percent( - user, - group, - category, - period_days=period_days, - ) - - async def calculate_subscription_total_cost( db: AsyncSession, period_days: int, @@ -857,7 +836,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_server_ids is None: additional_server_ids = [] - months_to_pay = max(1, get_remaining_months(subscription.end_date)) + months_to_pay = get_remaining_months(subscription.end_date) period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None total_cost = 0 @@ -868,7 +847,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_discount_percent = _get_addon_discount_percent( + traffic_discount_percent = _get_discount_percent( user, promo_group, "traffic", @@ -889,7 +868,7 @@ async def calculate_addon_cost_for_remaining_period( if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _get_addon_discount_percent( + devices_discount_percent = _get_discount_percent( user, promo_group, "devices", @@ -913,19 +892,12 @@ async def calculate_addon_cost_for_remaining_period( for server_id in additional_server_ids: result = await db.execute( select(ServerSquad.price_kopeks, ServerSquad.display_name) - .where( - ServerSquad.id == server_id, - ServerSquad.is_available.is_(True), - or_( - ServerSquad.max_users.is_(None), - ServerSquad.current_users < ServerSquad.max_users, - ), - ) + .where(ServerSquad.id == server_id) ) server_data = result.first() if server_data: server_price_per_month, server_name = server_data - servers_discount_percent = _get_addon_discount_percent( + servers_discount_percent = _get_discount_percent( user, promo_group, "servers", diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index d08f0b48..97b91d0b 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -93,51 +93,6 @@ def _apply_discount_to_monthly_component( } -def _get_addon_discount_percent_for_user( - user: User, - category: str, - period_days: Optional[int] = None, -) -> int: - promo_group = getattr(user, "promo_group", None) - - if promo_group is not None and not getattr(promo_group, "apply_discounts_to_addons", True): - return 0 - - try: - return user.get_promo_discount(category, period_days) - except AttributeError: - return 0 - - -def _calculate_discounted_addon_price( - subscription: Subscription, - user: User, - base_price_per_month: int, - category: str, -) -> Dict[str, int]: - months_to_pay = max(1, get_remaining_months(subscription.end_date)) - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None - discount_percent = _get_addon_discount_percent_for_user( - user, - category, - period_hint_days, - ) - - discount_per_month = base_price_per_month * discount_percent // 100 - discounted_per_month = base_price_per_month - discount_per_month - total_price = discounted_per_month * months_to_pay - total_discount = discount_per_month * months_to_pay - - return { - "total_price": total_price, - "charged_months": months_to_pay, - "discount_percent": discount_percent, - "discount_total": total_discount, - "discount_per_month": discount_per_month, - "discounted_per_month": discounted_per_month, - } - - async def _prepare_subscription_summary( db_user: User, data: Dict[str, Any], @@ -1332,64 +1287,35 @@ async def apply_countries_changes( logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") - months_to_pay = max(1, get_remaining_months(subscription.end_date)) - period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None - servers_discount_percent = _get_addon_discount_percent_for_user( - db_user, - "servers", - period_hint_days, - ) - + months_to_pay = get_remaining_months(subscription.end_date) + cost_per_month = 0 added_names = [] removed_names = [] - + added_server_prices = [] - total_cost = 0 - total_discount = 0 - + for country in countries: if country['uuid'] in added: server_price_per_month = country['price_kopeks'] cost_per_month += server_price_per_month added_names.append(country['name']) - server_discount_per_month = ( - server_price_per_month * servers_discount_percent // 100 - ) - discounted_per_month = server_price_per_month - server_discount_per_month - server_total_price = discounted_per_month * months_to_pay - added_server_prices.append(server_total_price) - total_cost += server_total_price - total_discount += server_discount_per_month * months_to_pay if country['uuid'] in removed: removed_names.append(country['name']) - - charged_months = months_to_pay - - if added and servers_discount_percent > 0: - logger.info( - "Стоимость новых серверов: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽)", - cost_per_month / 100, - charged_months, - total_cost / 100, - servers_discount_percent, - total_discount / 100, - ) - else: - logger.info( - "Стоимость новых серверов: %s₽/мес × %s мес = %s₽", - cost_per_month / 100, - charged_months, - total_cost / 100, - ) - + + total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) + + for country in countries: + if country['uuid'] in added: + server_price_per_month = country['price_kopeks'] + server_total_price = server_price_per_month * charged_months + added_server_prices.append(server_total_price) + + logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽") + if total_cost > 0 and db_user.balance_kopeks < total_cost: missing_kopeks = total_cost - db_user.balance_kopeks required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)" - if total_discount > 0: - required_text += ( - f"\n💸 Скидка {servers_discount_percent}%: -{texts.format_price(total_discount)}" - ) message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -1472,10 +1398,6 @@ async def apply_countries_changes( success_text += "\n".join(f"• {name}" for name in added_names) if total_cost > 0: success_text += f"\n💰 Списано: {texts.format_price(total_cost)} (за {charged_months} мес)" - if total_discount > 0: - success_text += ( - f"\n💸 Скидка {servers_discount_percent}%: -{texts.format_price(total_discount)}" - ) success_text += "\n" if removed_names: @@ -1571,6 +1493,8 @@ async def confirm_change_devices( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price + new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -1598,26 +1522,13 @@ async def confirm_change_devices( chargeable_devices = max(0, additional_devices - free_devices) else: chargeable_devices = additional_devices - + devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE - pricing = _calculate_discounted_addon_price( - subscription, - db_user, - devices_price_per_month, - "devices", - ) - price = pricing["total_price"] - charged_months = pricing["charged_months"] - discount_percent = pricing["discount_percent"] - discount_total = pricing["discount_total"] - + price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) + if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" - if discount_total > 0: - required_text += ( - f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" - ) message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -1643,13 +1554,9 @@ async def confirm_change_devices( ) await callback.answer() return - + action_text = f"увеличить до {new_devices_count}" cost_text = f"Доплата: {texts.format_price(price)} (за {charged_months} мес)" if price > 0 else "Бесплатно" - if price > 0 and discount_total > 0: - cost_text += ( - f" (скидка {discount_percent}%: -{texts.format_price(discount_total)})" - ) else: price = 0 @@ -1698,7 +1605,7 @@ async def execute_change_devices( await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - charged_months = max(1, get_remaining_months(subscription.end_date)) + charged_months = get_remaining_months(subscription.end_date) await create_transaction( db=db, user_id=db_user.id, @@ -2213,6 +2120,8 @@ async def confirm_add_devices( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price + devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -2230,43 +2139,13 @@ async def confirm_add_devices( return devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE - pricing = _calculate_discounted_addon_price( - subscription, - db_user, - devices_price_per_month, - "devices", - ) - price = pricing["total_price"] - charged_months = pricing["charged_months"] - discount_percent = pricing["discount_percent"] - discount_total = pricing["discount_total"] - - if discount_percent > 0: - logger.info( - "Добавление %s устройств: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽)", - devices_count, - devices_price_per_month / 100, - charged_months, - price / 100, - discount_percent, - discount_total / 100, - ) - else: - logger.info( - "Добавление %s устройств: %s₽/мес × %s мес = %s₽", - devices_count, - devices_price_per_month / 100, - charged_months, - price / 100, - ) + price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) + + logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽") if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" - if discount_total > 0: - required_text += ( - f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" - ) message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -2325,12 +2204,7 @@ async def confirm_add_devices( f"✅ Устройства успешно добавлены!\n\n" f"📱 Добавлено: {devices_count} устройств\n" f"Новый лимит: {subscription.device_limit} устройств\n" - f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)" - + ( - f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" - if discount_total > 0 - else "" - ), + f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)", reply_markup=get_back_keyboard(db_user.language) ) @@ -3627,42 +3501,12 @@ async def add_traffic( texts = get_texts(db_user.language) subscription = db_user.subscription - price_per_month = settings.get_traffic_price(traffic_gb) - - if price_per_month == 0 and traffic_gb != 0: + price = settings.get_traffic_price(traffic_gb) + + if price == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) return - - pricing = _calculate_discounted_addon_price( - subscription, - db_user, - price_per_month, - "traffic", - ) - price = pricing["total_price"] - charged_months = pricing["charged_months"] - discount_percent = pricing["discount_percent"] - discount_total = pricing["discount_total"] - - if discount_percent > 0: - logger.info( - "Добавление трафика +%s ГБ: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽)", - traffic_gb, - price_per_month / 100, - charged_months, - price / 100, - discount_percent, - discount_total / 100, - ) - else: - logger.info( - "Добавление трафика +%s ГБ: %s₽/мес × %s мес = %s₽", - traffic_gb, - price_per_month / 100, - charged_months, - price / 100, - ) - + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks message_text = texts.t( @@ -3675,14 +3519,7 @@ async def add_traffic( "Выберите способ пополнения. Сумма подставится автоматически." ), ).format( - required=( - f"{texts.format_price(price)} (за {charged_months} мес)" - + ( - f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" - if discount_total > 0 - else "" - ) - ), + required=texts.format_price(price), balance=texts.format_price(db_user.balance_kopeks), missing=texts.format_price(missing_kopeks), ) @@ -3701,7 +3538,7 @@ async def add_traffic( try: success = await subtract_user_balance( db, db_user, price, - f"Добавление {traffic_gb} ГБ трафика на {charged_months} мес" + f"Добавление {traffic_gb} ГБ трафика" ) if not success: @@ -3721,9 +3558,7 @@ async def add_traffic( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price, - description=( - f"Добавление {traffic_gb} ГБ трафика на {charged_months} мес" - ) + description=f"Добавление {traffic_gb} ГБ трафика" ) @@ -3736,12 +3571,6 @@ async def add_traffic( else: success_text += f"📈 Добавлено: {traffic_gb} ГБ\n" success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}" - if price > 0: - success_text += f"\n💰 Списано: {texts.format_price(price)} (за {charged_months} мес)" - if discount_total > 0: - success_text += ( - f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}" - ) await callback.message.edit_text( success_text, diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 4749b11a..54ba6720 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -14,8 +14,7 @@ from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, calculate_prorated_price, - validate_pricing_calculation, - resolve_addon_discount_percent, + validate_pricing_calculation ) logger = logging.getLogger(__name__) @@ -49,9 +48,12 @@ def _resolve_addon_discount_percent( ) -> int: group = promo_group or (getattr(user, "promo_group", None) if user else None) - return resolve_addon_discount_percent( + if group is not None and not getattr(group, "apply_discounts_to_addons", True): + return 0 + + return _resolve_discount_percent( user, - group, + promo_group, category, period_days=period_days, ) diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index ead9ddd4..40d7f589 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,14 +1,10 @@ from datetime import datetime, timedelta -from typing import Tuple, Optional, TYPE_CHECKING +from typing import Tuple import logging logger = logging.getLogger(__name__) -if TYPE_CHECKING: # pragma: no cover - from app.database.models import User, PromoGroup - - def calculate_months_from_days(days: int) -> int: return max(1, round(days / 30)) @@ -65,32 +61,6 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]: return discounted_amount, discount_value -def resolve_addon_discount_percent( - user: Optional["User"], - promo_group: Optional["PromoGroup"], - category: str, - *, - period_days: Optional[int] = None, -) -> int: - """Return discount percent for add-on purchases respecting promo-group rules.""" - - group = promo_group or (getattr(user, "promo_group", None) if user else None) - - if group is not None and not getattr(group, "apply_discounts_to_addons", True): - return 0 - - if user is not None: - try: - return user.get_promo_discount(category, period_days) - except AttributeError: - pass - - if promo_group is not None: - return promo_group.get_discount_percent(category, period_days) - - return 0 - - def format_period_description(days: int, language: str = "ru") -> str: months = calculate_months_from_days(days) From fbecf5bf7a868b5954ad1344fadfa3e9b9f296d1 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 14:06:18 +0300 Subject: [PATCH 127/146] Fix addon discount calculations and server availability --- app/handlers/subscription.py | 461 ++++++++++++++++++++++++++++++----- app/keyboards/inline.py | 77 ++++-- 2 files changed, 458 insertions(+), 80 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 97b91d0b..459eaa46 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -62,6 +62,7 @@ from app.utils.pricing_utils import ( calculate_prorated_price, validate_pricing_calculation, format_period_description, + apply_percentage_discount, ) from app.utils.pagination import paginate_list from app.utils.subscription_utils import ( @@ -74,13 +75,59 @@ logger = logging.getLogger(__name__) TRAFFIC_PRICES = get_traffic_prices() +def _get_addon_discount_percent_for_user( + user: Optional[User], + category: str, + period_days_hint: Optional[int] = None, +) -> int: + if user is None: + return 0 + + promo_group = getattr(user, "promo_group", None) + if promo_group is None: + return 0 + + if not getattr(promo_group, "apply_discounts_to_addons", True): + return 0 + + try: + return user.get_promo_discount(category, period_days_hint) + except AttributeError: + return 0 + + +def _apply_addon_discount( + user: Optional[User], + category: str, + amount: int, + period_days_hint: Optional[int] = None, +) -> Dict[str, int]: + percent = _get_addon_discount_percent_for_user(user, category, period_days_hint) + discounted_amount, discount_value = apply_percentage_discount(amount, percent) + + return { + "discounted": discounted_amount, + "discount": discount_value, + "percent": percent, + } + + +def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> Optional[int]: + if not subscription: + return None + + months_remaining = get_remaining_months(subscription.end_date) + if months_remaining <= 0: + return None + + return months_remaining * 30 + + def _apply_discount_to_monthly_component( amount_per_month: int, percent: int, months: int, ) -> Dict[str, int]: - from app.utils.pricing_utils import apply_percentage_discount - discounted_per_month, discount_per_month = apply_percentage_discount(amount_per_month, percent) return { @@ -1110,13 +1157,20 @@ async def handle_add_countries( texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True) return - + countries = await _get_available_countries(db_user.promo_group_id) current_countries = subscription.connected_squads + + period_hint_days = _get_period_hint_from_subscription(subscription) + servers_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "servers", + period_hint_days, + ) current_countries_names = [] for country in countries: @@ -1142,11 +1196,12 @@ async def handle_add_countries( await callback.message.edit_text( text, reply_markup=get_manage_countries_keyboard( - countries, - current_countries.copy(), - current_countries, + countries, + current_countries.copy(), + current_countries, db_user.language, - subscription.end_date + subscription.end_date, + servers_discount_percent, ), parse_mode="HTML" ) @@ -1228,14 +1283,22 @@ async def handle_manage_country( await state.update_data(countries=current_selected) + period_hint_days = _get_period_hint_from_subscription(subscription) + servers_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "servers", + period_hint_days, + ) + try: await callback.message.edit_reply_markup( reply_markup=get_manage_countries_keyboard( countries, - current_selected, - subscription.connected_squads, + current_selected, + subscription.connected_squads, db_user.language, - subscription.end_date + subscription.end_date, + servers_discount_percent, ) ) logger.info(f"✅ Клавиатура обновлена") @@ -1288,30 +1351,62 @@ async def apply_countries_changes( logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") months_to_pay = get_remaining_months(subscription.end_date) - + + period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None + servers_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "servers", + period_hint_days, + ) + cost_per_month = 0 added_names = [] removed_names = [] - - added_server_prices = [] - + + added_server_components: List[Dict[str, int]] = [] + for country in countries: + if not country.get('is_available', True): + continue + if country['uuid'] in added: server_price_per_month = country['price_kopeks'] - cost_per_month += server_price_per_month + discounted_per_month, discount_per_month = apply_percentage_discount( + server_price_per_month, + servers_discount_percent, + ) + cost_per_month += discounted_per_month added_names.append(country['name']) + added_server_components.append( + { + "discounted_per_month": discounted_per_month, + "discount_per_month": discount_per_month, + "original_per_month": server_price_per_month, + } + ) if country['uuid'] in removed: removed_names.append(country['name']) - + total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) - - for country in countries: - if country['uuid'] in added: - server_price_per_month = country['price_kopeks'] - server_total_price = server_price_per_month * charged_months - added_server_prices.append(server_total_price) - - logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽") + + added_server_prices = [ + component["discounted_per_month"] * charged_months + for component in added_server_components + ] + + total_discount = sum( + component["discount_per_month"] * charged_months + for component in added_server_components + ) + + if added_names: + logger.info( + "Стоимость новых серверов: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)", + cost_per_month / 100, + charged_months, + total_cost / 100, + total_discount / 100, + ) if total_cost > 0 and db_user.balance_kopeks < total_cost: missing_kopeks = total_cost - db_user.balance_kopeks @@ -1398,6 +1493,11 @@ async def apply_countries_changes( success_text += "\n".join(f"• {name}" for name in added_names) if total_cost > 0: success_text += f"\n💰 Списано: {texts.format_price(total_cost)} (за {charged_months} мес)" + if total_discount > 0: + success_text += ( + f" (скидка {servers_discount_percent}%:" + f" -{texts.format_price(total_discount)})" + ) success_text += "\n" if removed_names: @@ -1449,12 +1549,22 @@ async def handle_add_traffic( return current_traffic = subscription.traffic_limit_gb - + period_hint_days = _get_period_hint_from_subscription(subscription) + traffic_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "traffic", + period_hint_days, + ) + await callback.message.edit_text( f"📈 Добавить трафик к подписке\n\n" f"Текущий лимит: {texts.format_traffic(current_traffic)}\n" f"Выберите дополнительный трафик:", - reply_markup=get_add_traffic_keyboard(db_user.language, subscription.end_date), + reply_markup=get_add_traffic_keyboard( + db_user.language, + subscription.end_date, + traffic_discount_percent, + ), parse_mode="HTML" ) @@ -1474,7 +1584,14 @@ async def handle_change_devices( return current_devices = subscription.device_limit - + + period_hint_days = _get_period_hint_from_subscription(subscription) + devices_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "devices", + period_hint_days, + ) + await callback.message.edit_text( f"📱 Изменение количества устройств\n\n" f"Текущий лимит: {current_devices} устройств\n" @@ -1482,7 +1599,12 @@ async def handle_change_devices( f"💡 Важно:\n" f"• При увеличении - доплата пропорционально оставшемуся времени\n" f"• При уменьшении - возврат средств не производится", - reply_markup=get_change_devices_keyboard(current_devices, db_user.language, subscription.end_date), + reply_markup=get_change_devices_keyboard( + current_devices, + db_user.language, + subscription.end_date, + devices_discount_percent, + ), parse_mode="HTML" ) @@ -1524,7 +1646,22 @@ async def confirm_change_devices( chargeable_devices = additional_devices devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE - price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) + months_hint = get_remaining_months(subscription.end_date) + period_hint_days = months_hint * 30 if months_hint > 0 else None + devices_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "devices", + period_hint_days, + ) + discounted_per_month, discount_per_month = apply_percentage_discount( + devices_price_per_month, + devices_discount_percent, + ) + price, charged_months = calculate_prorated_price( + discounted_per_month, + subscription.end_date, + ) + total_discount = discount_per_month * charged_months if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks @@ -1556,7 +1693,15 @@ async def confirm_change_devices( return action_text = f"увеличить до {new_devices_count}" - cost_text = f"Доплата: {texts.format_price(price)} (за {charged_months} мес)" if price > 0 else "Бесплатно" + if price > 0: + cost_text = f"Доплата: {texts.format_price(price)} (за {charged_months} мес)" + if total_discount > 0: + cost_text += ( + f" (скидка {devices_discount_percent}%:" + f" -{texts.format_price(total_discount)})" + ) + else: + cost_text = "Бесплатно" else: price = 0 @@ -2139,9 +2284,31 @@ async def confirm_add_devices( return devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE - price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date) - - logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽") + months_hint = get_remaining_months(subscription.end_date) + period_hint_days = months_hint * 30 if months_hint > 0 else None + devices_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "devices", + period_hint_days, + ) + discounted_per_month, discount_per_month = apply_percentage_discount( + devices_price_per_month, + devices_discount_percent, + ) + price, charged_months = calculate_prorated_price( + discounted_per_month, + subscription.end_date, + ) + total_discount = discount_per_month * charged_months + + logger.info( + "Добавление %s устройств: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)", + devices_count, + discounted_per_month / 100, + charged_months, + price / 100, + total_discount / 100, + ) if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks @@ -2200,11 +2367,20 @@ async def confirm_add_devices( await db.refresh(db_user) await db.refresh(subscription) - await callback.message.edit_text( - f"✅ Устройства успешно добавлены!\n\n" + success_text = ( + "✅ Устройства успешно добавлены!\n\n" f"📱 Добавлено: {devices_count} устройств\n" f"Новый лимит: {subscription.device_limit} устройств\n" - f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)", + ) + success_text += f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)" + if total_discount > 0: + success_text += ( + f" (скидка {devices_discount_percent}%:" + f" -{texts.format_price(total_discount)})" + ) + + await callback.message.edit_text( + success_text, reply_markup=get_back_keyboard(db_user.language) ) @@ -3501,12 +3677,34 @@ async def add_traffic( texts = get_texts(db_user.language) subscription = db_user.subscription - price = settings.get_traffic_price(traffic_gb) - - if price == 0 and traffic_gb != 0: + base_price = settings.get_traffic_price(traffic_gb) + + if base_price == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) return - + + period_hint_days = _get_period_hint_from_subscription(subscription) + discount_result = _apply_addon_discount( + db_user, + "traffic", + base_price, + period_hint_days, + ) + + discounted_per_month = discount_result["discounted"] + discount_per_month = discount_result["discount"] + charged_months = 1 + + if subscription: + price, charged_months = calculate_prorated_price( + discounted_per_month, + subscription.end_date, + ) + else: + price = discounted_per_month + + total_discount_value = discount_per_month * charged_months + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks message_text = texts.t( @@ -3537,8 +3735,10 @@ async def add_traffic( try: success = await subtract_user_balance( - db, db_user, price, - f"Добавление {traffic_gb} ГБ трафика" + db, + db_user, + price, + f"Добавление {traffic_gb} ГБ трафика", ) if not success: @@ -3558,7 +3758,7 @@ async def add_traffic( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price, - description=f"Добавление {traffic_gb} ГБ трафика" + description=f"Добавление {traffic_gb} ГБ трафика", ) @@ -3571,7 +3771,15 @@ async def add_traffic( else: success_text += f"📈 Добавлено: {traffic_gb} ГБ\n" success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}" - + + if price > 0: + success_text += f"\n💰 Списано: {texts.format_price(price)}" + if total_discount_value > 0: + success_text += ( + f" (скидка {discount_result['percent']}%:" + f" -{texts.format_price(total_discount_value)})" + ) + await callback.message.edit_text( success_text, reply_markup=get_back_keyboard(db_user.language) @@ -3830,7 +4038,15 @@ async def _get_available_countries(promo_group_id: Optional[int] = None): available_servers = await get_available_server_squads( db, promo_group_id=promo_group_id ) - + + if promo_group_id is not None and not available_servers: + logger.info( + "Промогруппа %s не имеет доступных серверов, возврат пустого списка", + promo_group_id, + ) + await cache.set(cache_key_value, [], 60) + return [] + countries = [] for server in available_servers: countries.append({ @@ -3926,21 +4142,50 @@ async def handle_add_country_to_subscription( logger.info(f"🔍 Добавлена страна: {country_uuid}") total_price = 0 + subscription = db_user.subscription + period_hint_days = _get_period_hint_from_subscription(subscription) + servers_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "servers", + period_hint_days, + ) + for country in countries: - if country['uuid'] in selected_countries and country['uuid'] not in db_user.subscription.connected_squads: - total_price += country['price_kopeks'] - + if not country.get('is_available', True): + continue + + if ( + country['uuid'] in selected_countries + and country['uuid'] not in subscription.connected_squads + ): + server_price = country['price_kopeks'] + if servers_discount_percent > 0 and server_price > 0: + discounted_price, _ = apply_percentage_discount( + server_price, + servers_discount_percent, + ) + else: + discounted_price = server_price + total_price += discounted_price + data['countries'] = selected_countries data['total_price'] = total_price await state.set_data(data) - + logger.info(f"🔍 Новые выбранные страны: {selected_countries}") logger.info(f"🔍 Общая стоимость: {total_price}") try: from app.keyboards.inline import get_manage_countries_keyboard await callback.message.edit_reply_markup( - reply_markup=get_manage_countries_keyboard(countries, selected_countries, db_user.subscription.connected_squads, db_user.language) + reply_markup=get_manage_countries_keyboard( + countries, + selected_countries, + subscription.connected_squads, + db_user.language, + subscription.end_date, + servers_discount_percent, + ) ) logger.info(f"✅ Клавиатура обновлена") except Exception as e: @@ -3992,10 +4237,37 @@ async def confirm_add_countries_to_subscription( total_price = 0 new_countries_names = [] removed_countries_names = [] - + + period_hint_days = _get_period_hint_from_subscription(subscription) + servers_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "servers", + period_hint_days, + ) + total_discount_value = 0 + for country in countries: + if not country.get('is_available', True): + continue + if country['uuid'] in new_countries: - total_price += country['price_kopeks'] + server_price = country['price_kopeks'] + if servers_discount_percent > 0 and server_price > 0: + discounted_per_month, discount_per_month = apply_percentage_discount( + server_price, + servers_discount_percent, + ) + else: + discounted_per_month = server_price + discount_per_month = 0 + + charged_price, charged_months = calculate_prorated_price( + discounted_per_month, + subscription.end_date, + ) + + total_price += charged_price + total_discount_value += discount_per_month * charged_months new_countries_names.append(country['name']) if country['uuid'] in removed_countries: removed_countries_names.append(country['name']) @@ -4051,7 +4323,7 @@ async def confirm_add_countries_to_subscription( subscription.connected_squads = selected_countries subscription.updated_at = datetime.utcnow() await db.commit() - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) @@ -4063,7 +4335,13 @@ async def confirm_add_countries_to_subscription( if new_countries_names: success_text += f"➕ Добавлены страны:\n{chr(10).join(f'• {name}' for name in new_countries_names)}\n" if total_price > 0: - success_text += f"💰 Списано: {texts.format_price(total_price)}\n" + success_text += f"💰 Списано: {texts.format_price(total_price)}" + if total_discount_value > 0: + success_text += ( + f" (скидка {servers_discount_percent}%:" + f" -{texts.format_price(total_discount_value)})" + ) + success_text += "\n" if removed_countries_names: success_text += f"\n➖ Отключены страны:\n{chr(10).join(f'• {name}' for name in removed_countries_names)}\n" @@ -4880,7 +5158,13 @@ async def handle_switch_traffic( return current_traffic = subscription.traffic_limit_gb - + period_hint_days = _get_period_hint_from_subscription(subscription) + traffic_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "traffic", + period_hint_days, + ) + await callback.message.edit_text( f"🔄 Переключение лимита трафика\n\n" f"Текущий лимит: {texts.format_traffic(current_traffic)}\n" @@ -4888,7 +5172,12 @@ async def handle_switch_traffic( f"💡 Важно:\n" f"• При увеличении - доплата за разницу\n" f"• При уменьшении - возврат средств не производится", - reply_markup=get_traffic_switch_keyboard(current_traffic, db_user.language, subscription.end_date), + reply_markup=get_traffic_switch_keyboard( + current_traffic, + db_user.language, + subscription.end_date, + traffic_discount_percent, + ), parse_mode="HTML" ) @@ -4914,13 +5203,31 @@ async def confirm_switch_traffic( old_price_per_month = settings.get_traffic_price(current_traffic) new_price_per_month = settings.get_traffic_price(new_traffic_gb) - + months_remaining = get_remaining_months(subscription.end_date) - price_difference_per_month = new_price_per_month - old_price_per_month + period_hint_days = months_remaining * 30 if months_remaining > 0 else None + traffic_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "traffic", + period_hint_days, + ) + + discounted_old_per_month, _ = apply_percentage_discount( + old_price_per_month, + traffic_discount_percent, + ) + discounted_new_per_month, _ = apply_percentage_discount( + new_price_per_month, + traffic_discount_percent, + ) + price_difference_per_month = discounted_new_per_month - discounted_old_per_month + discount_savings_per_month = ( + (new_price_per_month - old_price_per_month) - price_difference_per_month + ) if price_difference_per_month > 0: total_price_difference = price_difference_per_month * months_remaining - + if db_user.balance_kopeks < total_price_difference: missing_kopeks = total_price_difference - db_user.balance_kopeks message_text = texts.t( @@ -4951,6 +5258,12 @@ async def confirm_switch_traffic( action_text = f"увеличить до {texts.format_traffic(new_traffic_gb)}" cost_text = f"Доплата: {texts.format_price(total_price_difference)} (за {months_remaining} мес)" + if discount_savings_per_month > 0: + total_discount_savings = discount_savings_per_month * months_remaining + cost_text += ( + f" (скидка {traffic_discount_percent}%:" + f" -{texts.format_price(total_discount_savings)})" + ) else: total_price_difference = 0 action_text = f"уменьшить до {texts.format_traffic(new_traffic_gb)}" @@ -5070,9 +5383,10 @@ async def execute_switch_traffic( def get_traffic_switch_keyboard( - current_traffic_gb: int, - language: str = "ru", - subscription_end_date: datetime = None + current_traffic_gb: int, + language: str = "ru", + subscription_end_date: datetime = None, + discount_percent: int = 0, ) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months from app.config import settings @@ -5088,16 +5402,24 @@ def get_traffic_switch_keyboard( enabled_packages = [pkg for pkg in packages if pkg['enabled']] current_price_per_month = settings.get_traffic_price(current_traffic_gb) + discounted_current_per_month, _ = apply_percentage_discount( + current_price_per_month, + discount_percent, + ) buttons = [] for package in enabled_packages: gb = package['gb'] price_per_month = package['price'] - - price_diff_per_month = price_per_month - current_price_per_month + discounted_price_per_month, _ = apply_percentage_discount( + price_per_month, + discount_percent, + ) + + price_diff_per_month = discounted_price_per_month - discounted_current_per_month total_price_diff = price_diff_per_month * months_multiplier - + if gb == current_traffic_gb: emoji = "✅" action_text = " (текущий)" @@ -5106,6 +5428,13 @@ def get_traffic_switch_keyboard( emoji = "⬆️" action_text = "" price_text = f" (+{total_price_diff//100}₽{period_text})" + if discount_percent > 0: + discount_total = ( + (price_per_month - current_price_per_month) * months_multiplier + - total_price_diff + ) + if discount_total > 0: + price_text += f" (скидка {discount_percent}%: -{discount_total//100}₽)" elif total_price_diff < 0: emoji = "⬇️" action_text = "" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index adb4462f..a57c6cf6 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts -from app.utils.pricing_utils import format_period_description +from app.utils.pricing_utils import format_period_description, apply_percentage_discount from app.utils.subscription_utils import ( get_display_subscription_link, get_happ_cryptolink_redirect_link, @@ -1123,7 +1123,11 @@ def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup: +def get_add_traffic_keyboard( + language: str = DEFAULT_LANGUAGE, + subscription_end_date: datetime = None, + discount_percent: int = 0, +) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months from app.config import settings texts = get_texts(language) @@ -1155,8 +1159,13 @@ def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_ for package in enabled_packages: gb = package['gb'] price_per_month = package['price'] - total_price = price_per_month * months_multiplier - + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + discount_percent, + ) + total_price = discounted_per_month * months_multiplier + total_discount = discount_per_month * months_multiplier + if gb == 0: if language == "ru": text = f"♾️ Безлимитный трафик - {total_price//100} ₽{period_text}" @@ -1167,7 +1176,10 @@ def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_ text = f"📊 +{gb} ГБ трафика - {total_price//100} ₽{period_text}" else: text = f"📊 +{gb} GB traffic - {total_price//100} ₽{period_text}" - + + if discount_percent > 0 and total_discount > 0: + text += f" (скидка {discount_percent}%: -{total_discount//100}₽)" + buttons.append([ InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}") ]) @@ -1181,7 +1193,12 @@ def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_ return InlineKeyboardMarkup(inline_keyboard=buttons) -def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup: +def get_change_devices_keyboard( + current_devices: int, + language: str = DEFAULT_LANGUAGE, + subscription_end_date: datetime = None, + discount_percent: int = 0, +) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months from app.config import settings texts = get_texts(language) @@ -1218,8 +1235,17 @@ def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LA if chargeable_devices > 0: price_per_month = chargeable_devices * device_price_per_month - total_price = price_per_month * months_multiplier + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + discount_percent, + ) + total_price = discounted_per_month * months_multiplier price_text = f" (+{total_price//100}₽{period_text})" + if discount_percent > 0 and discount_per_month * months_multiplier > 0: + price_text += ( + f" (скидка {discount_percent}%:" + f" -{(discount_per_month * months_multiplier)//100}₽)" + ) action_text = "" else: price_text = " (бесплатно)" @@ -1296,7 +1322,8 @@ def get_manage_countries_keyboard( selected: List[str], current_subscription_countries: List[str], language: str = DEFAULT_LANGUAGE, - subscription_end_date: datetime = None + subscription_end_date: datetime = None, + discount_percent: int = 0, ) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months @@ -1311,10 +1338,18 @@ def get_manage_countries_keyboard( total_cost = 0 for country in countries: + if not country.get('is_available', True): + continue + uuid = country['uuid'] name = country['name'] price_per_month = country['price_kopeks'] - + + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + discount_percent, + ) + if uuid in current_subscription_countries: if uuid in selected: icon = "✅" @@ -1323,17 +1358,31 @@ def get_manage_countries_keyboard( else: if uuid in selected: icon = "➕" - total_cost += price_per_month * months_multiplier + total_cost += discounted_per_month * months_multiplier else: icon = "⚪" - + if uuid not in current_subscription_countries and uuid in selected: - total_price = price_per_month * months_multiplier + total_price = discounted_per_month * months_multiplier if months_multiplier > 1: - price_text = f" ({price_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)" - logger.info(f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес = {total_price/100}₽") + price_text = ( + f" ({discounted_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)" + ) + logger.info( + "🔍 Сервер %s: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)", + name, + discounted_per_month / 100, + months_multiplier, + total_price / 100, + (discount_per_month * months_multiplier) / 100, + ) else: price_text = f" ({total_price//100}₽)" + if discount_percent > 0 and discount_per_month * months_multiplier > 0: + price_text += ( + f" (скидка {discount_percent}%:" + f" -{(discount_per_month * months_multiplier)//100}₽)" + ) display_name = f"{icon} {name}{price_text}" else: display_name = f"{icon} {name}" From 7316e4aa486b3e7705a000e7c24a4fc83bc49172 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 14:16:10 +0300 Subject: [PATCH 128/146] Fix server management visibility for promo groups --- app/database/crud/server_squad.py | 21 +++++++++++++++++++++ app/handlers/subscription.py | 23 ++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index 85f1dc58..700104bc 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -146,6 +146,27 @@ async def get_available_server_squads( return result.scalars().unique().all() +async def get_promo_group_server_count( + db: AsyncSession, + promo_group_id: int, + *, + include_unavailable: bool = True, +) -> int: + """Возвращает количество серверов, связанных с промогруппой.""" + + query = ( + select(func.count(func.distinct(ServerSquad.id))) + .join(ServerSquad.allowed_promo_groups) + .where(PromoGroup.id == promo_group_id) + ) + + if not include_unavailable: + query = query.where(ServerSquad.is_available.is_(True)) + + result = await db.execute(query) + return result.scalar_one_or_none() or 0 + + async def update_server_squad_promo_groups( db: AsyncSession, server_id: int, promo_group_ids: Iterable[int] ) -> Optional[ServerSquad]: diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 459eaa46..a1ff5e66 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -4198,7 +4198,28 @@ async def _should_show_countries_management(user: Optional[User] = None) -> bool promo_group_id = user.promo_group_id if user else None countries = await _get_available_countries(promo_group_id) available_countries = [c for c in countries if c.get('is_available', True)] - return len(available_countries) > 1 + if len(available_countries) > 1: + return True + + if user and getattr(user, "subscription", None): + connected = user.subscription.connected_squads or [] + if len(set(connected)) > 1: + return True + + if promo_group_id is not None: + from app.database.database import AsyncSessionLocal + from app.database.crud.server_squad import get_promo_group_server_count + + async with AsyncSessionLocal() as db: + total_servers = await get_promo_group_server_count( + db, + promo_group_id, + include_unavailable=True, + ) + + return total_servers > 1 + + return False except Exception as e: logger.error(f"Ошибка проверки доступных серверов: {e}") return True From 241a053252a449ba1370847e70beed7d4b1ab879 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 14:17:15 +0300 Subject: [PATCH 129/146] Revert "Fix server management visibility for promo groups" --- app/database/crud/server_squad.py | 21 --------------------- app/handlers/subscription.py | 23 +---------------------- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index 700104bc..85f1dc58 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -146,27 +146,6 @@ async def get_available_server_squads( return result.scalars().unique().all() -async def get_promo_group_server_count( - db: AsyncSession, - promo_group_id: int, - *, - include_unavailable: bool = True, -) -> int: - """Возвращает количество серверов, связанных с промогруппой.""" - - query = ( - select(func.count(func.distinct(ServerSquad.id))) - .join(ServerSquad.allowed_promo_groups) - .where(PromoGroup.id == promo_group_id) - ) - - if not include_unavailable: - query = query.where(ServerSquad.is_available.is_(True)) - - result = await db.execute(query) - return result.scalar_one_or_none() or 0 - - async def update_server_squad_promo_groups( db: AsyncSession, server_id: int, promo_group_ids: Iterable[int] ) -> Optional[ServerSquad]: diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index a1ff5e66..459eaa46 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -4198,28 +4198,7 @@ async def _should_show_countries_management(user: Optional[User] = None) -> bool promo_group_id = user.promo_group_id if user else None countries = await _get_available_countries(promo_group_id) available_countries = [c for c in countries if c.get('is_available', True)] - if len(available_countries) > 1: - return True - - if user and getattr(user, "subscription", None): - connected = user.subscription.connected_squads or [] - if len(set(connected)) > 1: - return True - - if promo_group_id is not None: - from app.database.database import AsyncSessionLocal - from app.database.crud.server_squad import get_promo_group_server_count - - async with AsyncSessionLocal() as db: - total_servers = await get_promo_group_server_count( - db, - promo_group_id, - include_unavailable=True, - ) - - return total_servers > 1 - - return False + return len(available_countries) > 1 except Exception as e: logger.error(f"Ошибка проверки доступных серверов: {e}") return True From cefd39f7e96ec90b12742c6f17e3d09cc49650dc Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 14:18:11 +0300 Subject: [PATCH 130/146] Ensure server management button appears when multiple options exist --- app/handlers/subscription.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 459eaa46..72773ede 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -4197,8 +4197,33 @@ async def _should_show_countries_management(user: Optional[User] = None) -> bool try: promo_group_id = user.promo_group_id if user else None countries = await _get_available_countries(promo_group_id) + + # Базовая проверка — доступно более одного сервера available_countries = [c for c in countries if c.get('is_available', True)] - return len(available_countries) > 1 + if len(available_countries) > 1: + return True + + # Если серверов формально больше одного, но часть из них временно недоступна + # (например, переполнены), все равно показываем кнопку управления, чтобы + # пользователь видел варианты переключения. + unique_countries = {c.get('uuid') for c in countries if c.get('uuid')} + if len(unique_countries) > 1: + return True + + # Для действующих подписок учитываем уже подключенные сервера — это позволит + # оставить кнопку, даже если доступен только один новый сервер, но пользователь + # уже использует несколько стран. + if user and getattr(user, "subscription", None): + current_countries = set(user.subscription.connected_squads or []) + if len(current_countries) > 1: + return True + + if current_countries: + total_options = current_countries.union(unique_countries) + if len(total_options) > 1: + return True + + return False except Exception as e: logger.error(f"Ошибка проверки доступных серверов: {e}") return True From 1c1f7ade784b717b8d7f33ec2483867e2b7c18ad Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 14:22:36 +0300 Subject: [PATCH 131/146] Revert "Fix server management visibility for promo groups" --- app/handlers/subscription.py | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 72773ede..459eaa46 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -4197,33 +4197,8 @@ async def _should_show_countries_management(user: Optional[User] = None) -> bool try: promo_group_id = user.promo_group_id if user else None countries = await _get_available_countries(promo_group_id) - - # Базовая проверка — доступно более одного сервера available_countries = [c for c in countries if c.get('is_available', True)] - if len(available_countries) > 1: - return True - - # Если серверов формально больше одного, но часть из них временно недоступна - # (например, переполнены), все равно показываем кнопку управления, чтобы - # пользователь видел варианты переключения. - unique_countries = {c.get('uuid') for c in countries if c.get('uuid')} - if len(unique_countries) > 1: - return True - - # Для действующих подписок учитываем уже подключенные сервера — это позволит - # оставить кнопку, даже если доступен только один новый сервер, но пользователь - # уже использует несколько стран. - if user and getattr(user, "subscription", None): - current_countries = set(user.subscription.connected_squads or []) - if len(current_countries) > 1: - return True - - if current_countries: - total_options = current_countries.union(unique_countries) - if len(total_options) > 1: - return True - - return False + return len(available_countries) > 1 except Exception as e: logger.error(f"Ошибка проверки доступных серверов: {e}") return True From 9b477a700d8831dbd67f6297cc0f05765f94d1d6 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 14:23:32 +0300 Subject: [PATCH 132/146] Ensure server management button respects promo group servers --- app/handlers/subscription.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 459eaa46..f3b7b57d 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -4196,6 +4196,32 @@ async def handle_add_country_to_subscription( async def _should_show_countries_management(user: Optional[User] = None) -> bool: try: promo_group_id = user.promo_group_id if user else None + + promo_group = getattr(user, "promo_group", None) if user else None + if promo_group and getattr(promo_group, "server_squads", None): + allowed_servers = [ + server + for server in promo_group.server_squads + if server.is_available and not server.is_full + ] + + if len(allowed_servers) > 1: + logger.debug( + "Промогруппа %s имеет %s доступных серверов, показываем управление странами", + promo_group.id, + len(allowed_servers), + ) + return True + + if len(promo_group.server_squads) > 1: + logger.debug( + "Промогруппа %s имеет %s серверов, но доступен только %s — показываем управление странами", + promo_group.id, + len(promo_group.server_squads), + len(allowed_servers), + ) + return True + countries = await _get_available_countries(promo_group_id) available_countries = [c for c in countries if c.get('is_available', True)] return len(available_countries) > 1 From 137ac3c58c8c1a0813fdcaead6cd1966e033a179 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 14:36:26 +0300 Subject: [PATCH 133/146] Respect manual promo group overrides --- app/services/promo_group_assignment.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/app/services/promo_group_assignment.py b/app/services/promo_group_assignment.py index 8238c642..3afc356e 100644 --- a/app/services/promo_group_assignment.py +++ b/app/services/promo_group_assignment.py @@ -54,13 +54,25 @@ async def maybe_assign_promo_group_by_total_spent( try: previous_group_id = user.promo_group_id - if user.auto_promo_group_assigned and target_group.id == previous_group_id: + if user.auto_promo_group_assigned: + if target_group.id == previous_group_id: + logger.debug( + "Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется", + user.telegram_id, + target_group.name, + ) + return target_group + + current_group_name = ( + user.promo_group.name if getattr(user, "promo_group", None) else str(previous_group_id) + ) logger.debug( - "Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется", + "Пользователь %s уже получал автопромогруппу '%s', но сейчас установлена '%s' вручную — пропускаем переназначение", user.telegram_id, target_group.name, + current_group_name, ) - return target_group + return None user.auto_promo_group_assigned = True user.updated_at = datetime.utcnow() From 6f834aeec0b2856238ced3d5dc8e810ebd4b7c2a Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 14:43:45 +0300 Subject: [PATCH 134/146] Revert "Prevent auto promo group overrides after manual change" --- app/services/promo_group_assignment.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/app/services/promo_group_assignment.py b/app/services/promo_group_assignment.py index 3afc356e..8238c642 100644 --- a/app/services/promo_group_assignment.py +++ b/app/services/promo_group_assignment.py @@ -54,25 +54,13 @@ async def maybe_assign_promo_group_by_total_spent( try: previous_group_id = user.promo_group_id - if user.auto_promo_group_assigned: - if target_group.id == previous_group_id: - logger.debug( - "Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется", - user.telegram_id, - target_group.name, - ) - return target_group - - current_group_name = ( - user.promo_group.name if getattr(user, "promo_group", None) else str(previous_group_id) - ) + if user.auto_promo_group_assigned and target_group.id == previous_group_id: logger.debug( - "Пользователь %s уже получал автопромогруппу '%s', но сейчас установлена '%s' вручную — пропускаем переназначение", + "Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется", user.telegram_id, target_group.name, - current_group_name, ) - return None + return target_group user.auto_promo_group_assigned = True user.updated_at = datetime.utcnow() From a6da530848943f25d87b960493024aa0f7022ab7 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 14:54:46 +0300 Subject: [PATCH 135/146] Track last auto promo group to avoid duplicate assignments --- app/database/models.py | 10 +++ app/database/universal_migration.py | 34 +++++++++ app/services/promo_group_assignment.py | 10 ++- app/services/user_service.py | 1 + ...7b1c7a3a4f_add_last_auto_promo_group_id.py | 74 +++++++++++++++++++ 5 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 migrations/alembic/versions/9d7b1c7a3a4f_add_last_auto_promo_group_id.py diff --git a/app/database/models.py b/app/database/models.py index 278178ff..9af84ba2 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -388,6 +388,16 @@ class User(Base): discount_offers = relationship("DiscountOffer", back_populates="user") lifetime_used_traffic_bytes = Column(BigInteger, default=0) auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) + last_auto_promo_group_id = Column( + Integer, + ForeignKey("promo_groups.id", ondelete="SET NULL"), + nullable=True, + ) + last_auto_promo_group = relationship( + "PromoGroup", + foreign_keys=[last_auto_promo_group_id], + post_update=True, + ) last_remnawave_sync = Column(DateTime, nullable=True) trojan_password = Column(String(255), nullable=True) vless_uuid = Column(String(255), nullable=True) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 8f5648c5..bcdd0324 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1025,6 +1025,37 @@ async def ensure_promo_groups_setup(): logger.info("Добавлена колонка users.auto_promo_group_assigned") + last_auto_group_column_exists = await check_column_exists( + "users", "last_auto_promo_group_id" + ) + + if not last_auto_group_column_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN last_auto_promo_group_id INTEGER" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN last_auto_promo_group_id INTEGER" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN last_auto_promo_group_id INT" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для users.last_auto_promo_group_id: {db_type}" + ) + return False + + logger.info("Добавлена колонка users.last_auto_promo_group_id") + index_exists = await check_index_exists("users", "ix_users_promo_group_id") if not index_exists: @@ -2044,6 +2075,7 @@ async def check_migration_status(): "promo_groups_auto_assign_column": False, "promo_groups_addon_discount_column": False, "users_auto_promo_group_assigned_column": False, + "users_last_auto_promo_group_column": False, "subscription_crypto_link_column": False, } @@ -2062,6 +2094,7 @@ async def check_migration_status(): status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') + status["users_last_auto_promo_group_column"] = await check_column_exists('users', 'last_auto_promo_group_id') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') media_fields_exist = ( @@ -2100,6 +2133,7 @@ async def check_migration_status(): "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", "promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", + "users_last_auto_promo_group_column": "Колонка последней автопромогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/services/promo_group_assignment.py b/app/services/promo_group_assignment.py index 8238c642..b15df812 100644 --- a/app/services/promo_group_assignment.py +++ b/app/services/promo_group_assignment.py @@ -53,16 +53,22 @@ async def maybe_assign_promo_group_by_total_spent( try: previous_group_id = user.promo_group_id + last_auto_group_id = getattr(user, "last_auto_promo_group_id", None) - if user.auto_promo_group_assigned and target_group.id == previous_group_id: + if ( + last_auto_group_id == target_group.id + and previous_group_id != target_group.id + ): logger.debug( - "Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется", + "Пользователь %s ранее уже получал промогруппу '%s', пропускаем повторное назначение", user.telegram_id, target_group.name, ) return target_group user.auto_promo_group_assigned = True + user.last_auto_promo_group_id = target_group.id + user.last_auto_promo_group = target_group user.updated_at = datetime.utcnow() if target_group.id != previous_group_id: diff --git a/app/services/user_service.py b/app/services/user_service.py index 5b7f57b4..2a0a7e99 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -243,6 +243,7 @@ class UserService: user.promo_group_id = promo_group.id user.promo_group = promo_group + user.auto_promo_group_assigned = False user.updated_at = datetime.utcnow() await db.commit() diff --git a/migrations/alembic/versions/9d7b1c7a3a4f_add_last_auto_promo_group_id.py b/migrations/alembic/versions/9d7b1c7a3a4f_add_last_auto_promo_group_id.py new file mode 100644 index 00000000..0cf9036a --- /dev/null +++ b/migrations/alembic/versions/9d7b1c7a3a4f_add_last_auto_promo_group_id.py @@ -0,0 +1,74 @@ +""" +Add last_auto_promo_group_id column to users. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine.reflection import Inspector + + +revision: str = "9d7b1c7a3a4f" +down_revision: Union[str, None] = "8fd1e338eb45" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +TABLE_NAME = "users" +COLUMN_NAME = "last_auto_promo_group_id" +INDEX_NAME = "ix_users_last_auto_promo_group_id" +FK_NAME = "fk_users_last_auto_promo_group_id" + + +def _column_exists(inspector: Inspector) -> bool: + return COLUMN_NAME in {column["name"] for column in inspector.get_columns(TABLE_NAME)} + + +def _index_exists(inspector: Inspector) -> bool: + return INDEX_NAME in {index["name"] for index in inspector.get_indexes(TABLE_NAME)} + + +def _fk_exists(inspector: Inspector) -> bool: + return FK_NAME in {fk["name"] for fk in inspector.get_foreign_keys(TABLE_NAME)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _column_exists(inspector): + op.add_column( + TABLE_NAME, + sa.Column(COLUMN_NAME, sa.Integer(), nullable=True), + ) + inspector = sa.inspect(bind) + + if _column_exists(inspector) and not _fk_exists(inspector): + op.create_foreign_key( + FK_NAME, + TABLE_NAME, + "promo_groups", + [COLUMN_NAME], + ["id"], + ondelete="SET NULL", + ) + inspector = sa.inspect(bind) + + if _column_exists(inspector) and not _index_exists(inspector): + op.create_index(INDEX_NAME, TABLE_NAME, [COLUMN_NAME]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _index_exists(inspector): + op.drop_index(INDEX_NAME, table_name=TABLE_NAME) + inspector = sa.inspect(bind) + + if _fk_exists(inspector): + op.drop_constraint(FK_NAME, TABLE_NAME, type_="foreignkey") + inspector = sa.inspect(bind) + + if _column_exists(inspector): + op.drop_column(TABLE_NAME, COLUMN_NAME) From 00cd0bb5b094c39f21802c48aefc26ac889de687 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 14:56:08 +0300 Subject: [PATCH 136/146] Revert "Track last auto promo group to avoid duplicate assignments" --- app/database/models.py | 10 --- app/database/universal_migration.py | 34 --------- app/services/promo_group_assignment.py | 10 +-- app/services/user_service.py | 1 - ...7b1c7a3a4f_add_last_auto_promo_group_id.py | 74 ------------------- 5 files changed, 2 insertions(+), 127 deletions(-) delete mode 100644 migrations/alembic/versions/9d7b1c7a3a4f_add_last_auto_promo_group_id.py diff --git a/app/database/models.py b/app/database/models.py index 9af84ba2..278178ff 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -388,16 +388,6 @@ class User(Base): discount_offers = relationship("DiscountOffer", back_populates="user") lifetime_used_traffic_bytes = Column(BigInteger, default=0) auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) - last_auto_promo_group_id = Column( - Integer, - ForeignKey("promo_groups.id", ondelete="SET NULL"), - nullable=True, - ) - last_auto_promo_group = relationship( - "PromoGroup", - foreign_keys=[last_auto_promo_group_id], - post_update=True, - ) last_remnawave_sync = Column(DateTime, nullable=True) trojan_password = Column(String(255), nullable=True) vless_uuid = Column(String(255), nullable=True) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index bcdd0324..8f5648c5 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1025,37 +1025,6 @@ async def ensure_promo_groups_setup(): logger.info("Добавлена колонка users.auto_promo_group_assigned") - last_auto_group_column_exists = await check_column_exists( - "users", "last_auto_promo_group_id" - ) - - if not last_auto_group_column_exists: - if db_type == "sqlite": - await conn.execute( - text( - "ALTER TABLE users ADD COLUMN last_auto_promo_group_id INTEGER" - ) - ) - elif db_type == "postgresql": - await conn.execute( - text( - "ALTER TABLE users ADD COLUMN last_auto_promo_group_id INTEGER" - ) - ) - elif db_type == "mysql": - await conn.execute( - text( - "ALTER TABLE users ADD COLUMN last_auto_promo_group_id INT" - ) - ) - else: - logger.error( - f"Неподдерживаемый тип БД для users.last_auto_promo_group_id: {db_type}" - ) - return False - - logger.info("Добавлена колонка users.last_auto_promo_group_id") - index_exists = await check_index_exists("users", "ix_users_promo_group_id") if not index_exists: @@ -2075,7 +2044,6 @@ async def check_migration_status(): "promo_groups_auto_assign_column": False, "promo_groups_addon_discount_column": False, "users_auto_promo_group_assigned_column": False, - "users_last_auto_promo_group_column": False, "subscription_crypto_link_column": False, } @@ -2094,7 +2062,6 @@ async def check_migration_status(): status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') - status["users_last_auto_promo_group_column"] = await check_column_exists('users', 'last_auto_promo_group_id') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') media_fields_exist = ( @@ -2133,7 +2100,6 @@ async def check_migration_status(): "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", "promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", - "users_last_auto_promo_group_column": "Колонка последней автопромогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/services/promo_group_assignment.py b/app/services/promo_group_assignment.py index b15df812..8238c642 100644 --- a/app/services/promo_group_assignment.py +++ b/app/services/promo_group_assignment.py @@ -53,22 +53,16 @@ async def maybe_assign_promo_group_by_total_spent( try: previous_group_id = user.promo_group_id - last_auto_group_id = getattr(user, "last_auto_promo_group_id", None) - if ( - last_auto_group_id == target_group.id - and previous_group_id != target_group.id - ): + if user.auto_promo_group_assigned and target_group.id == previous_group_id: logger.debug( - "Пользователь %s ранее уже получал промогруппу '%s', пропускаем повторное назначение", + "Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется", user.telegram_id, target_group.name, ) return target_group user.auto_promo_group_assigned = True - user.last_auto_promo_group_id = target_group.id - user.last_auto_promo_group = target_group user.updated_at = datetime.utcnow() if target_group.id != previous_group_id: diff --git a/app/services/user_service.py b/app/services/user_service.py index 2a0a7e99..5b7f57b4 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -243,7 +243,6 @@ class UserService: user.promo_group_id = promo_group.id user.promo_group = promo_group - user.auto_promo_group_assigned = False user.updated_at = datetime.utcnow() await db.commit() diff --git a/migrations/alembic/versions/9d7b1c7a3a4f_add_last_auto_promo_group_id.py b/migrations/alembic/versions/9d7b1c7a3a4f_add_last_auto_promo_group_id.py deleted file mode 100644 index 0cf9036a..00000000 --- a/migrations/alembic/versions/9d7b1c7a3a4f_add_last_auto_promo_group_id.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -Add last_auto_promo_group_id column to users. -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.engine.reflection import Inspector - - -revision: str = "9d7b1c7a3a4f" -down_revision: Union[str, None] = "8fd1e338eb45" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -TABLE_NAME = "users" -COLUMN_NAME = "last_auto_promo_group_id" -INDEX_NAME = "ix_users_last_auto_promo_group_id" -FK_NAME = "fk_users_last_auto_promo_group_id" - - -def _column_exists(inspector: Inspector) -> bool: - return COLUMN_NAME in {column["name"] for column in inspector.get_columns(TABLE_NAME)} - - -def _index_exists(inspector: Inspector) -> bool: - return INDEX_NAME in {index["name"] for index in inspector.get_indexes(TABLE_NAME)} - - -def _fk_exists(inspector: Inspector) -> bool: - return FK_NAME in {fk["name"] for fk in inspector.get_foreign_keys(TABLE_NAME)} - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not _column_exists(inspector): - op.add_column( - TABLE_NAME, - sa.Column(COLUMN_NAME, sa.Integer(), nullable=True), - ) - inspector = sa.inspect(bind) - - if _column_exists(inspector) and not _fk_exists(inspector): - op.create_foreign_key( - FK_NAME, - TABLE_NAME, - "promo_groups", - [COLUMN_NAME], - ["id"], - ondelete="SET NULL", - ) - inspector = sa.inspect(bind) - - if _column_exists(inspector) and not _index_exists(inspector): - op.create_index(INDEX_NAME, TABLE_NAME, [COLUMN_NAME]) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if _index_exists(inspector): - op.drop_index(INDEX_NAME, table_name=TABLE_NAME) - inspector = sa.inspect(bind) - - if _fk_exists(inspector): - op.drop_constraint(FK_NAME, TABLE_NAME, type_="foreignkey") - inspector = sa.inspect(bind) - - if _column_exists(inspector): - op.drop_column(TABLE_NAME, COLUMN_NAME) From 98a241a301739a4248f2697098e24c3b2df4c1e6 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 15:05:53 +0300 Subject: [PATCH 137/146] Prevent repeated auto promo group assignments --- app/database/models.py | 1 + app/database/universal_migration.py | 36 ++++++++++++++++++++++++++ app/services/promo_group_assignment.py | 32 +++++++++++++++++++++-- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/app/database/models.py b/app/database/models.py index 278178ff..bc36c94e 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -388,6 +388,7 @@ class User(Base): discount_offers = relationship("DiscountOffer", back_populates="user") lifetime_used_traffic_bytes = Column(BigInteger, default=0) auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) + auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0) last_remnawave_sync = Column(DateTime, nullable=True) trojan_password = Column(String(255), nullable=True) vless_uuid = Column(String(255), nullable=True) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 8f5648c5..b0edb1b6 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1025,6 +1025,39 @@ async def ensure_promo_groups_setup(): logger.info("Добавлена колонка users.auto_promo_group_assigned") + threshold_column_exists = await check_column_exists( + "users", "auto_promo_group_threshold_kopeks" + ) + + if not threshold_column_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN auto_promo_group_threshold_kopeks INTEGER NOT NULL DEFAULT 0" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN auto_promo_group_threshold_kopeks BIGINT NOT NULL DEFAULT 0" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE users ADD COLUMN auto_promo_group_threshold_kopeks BIGINT NOT NULL DEFAULT 0" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для users.auto_promo_group_threshold_kopeks: {db_type}" + ) + return False + + logger.info( + "Добавлена колонка users.auto_promo_group_threshold_kopeks" + ) + index_exists = await check_index_exists("users", "ix_users_promo_group_id") if not index_exists: @@ -2044,6 +2077,7 @@ async def check_migration_status(): "promo_groups_auto_assign_column": False, "promo_groups_addon_discount_column": False, "users_auto_promo_group_assigned_column": False, + "users_auto_promo_group_threshold_column": False, "subscription_crypto_link_column": False, } @@ -2062,6 +2096,7 @@ async def check_migration_status(): status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') + status["users_auto_promo_group_threshold_column"] = await check_column_exists('users', 'auto_promo_group_threshold_kopeks') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') media_fields_exist = ( @@ -2100,6 +2135,7 @@ async def check_migration_status(): "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", "promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", + "users_auto_promo_group_threshold_column": "Порог последней авто-промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/services/promo_group_assignment.py b/app/services/promo_group_assignment.py index 8238c642..43b29ec1 100644 --- a/app/services/promo_group_assignment.py +++ b/app/services/promo_group_assignment.py @@ -14,6 +14,7 @@ logger = logging.getLogger(__name__) async def _get_best_group_for_spending( db: AsyncSession, total_spent_kopeks: int, + min_threshold_kopeks: int = 0, ) -> Optional[PromoGroup]: if total_spent_kopeks <= 0: return None @@ -28,7 +29,11 @@ async def _get_best_group_for_spending( for group in groups: threshold = group.auto_assign_total_spent_kopeks or 0 - if threshold and total_spent_kopeks >= threshold: + if ( + threshold + and total_spent_kopeks >= threshold + and threshold > min_threshold_kopeks + ): return group return None @@ -47,12 +52,29 @@ async def maybe_assign_promo_group_by_total_spent( if total_spent <= 0: return None - target_group = await _get_best_group_for_spending(db, total_spent) + previous_threshold = user.auto_promo_group_threshold_kopeks or 0 + + target_group = await _get_best_group_for_spending( + db, + total_spent, + min_threshold_kopeks=previous_threshold, + ) if not target_group: return None try: previous_group_id = user.promo_group_id + target_threshold = target_group.auto_assign_total_spent_kopeks or 0 + + if target_threshold <= previous_threshold: + logger.debug( + "Порог промогруппы '%s' (%s) не превышает ранее назначенный (%s) для пользователя %s", + target_group.name, + target_threshold, + previous_threshold, + user.telegram_id, + ) + return None if user.auto_promo_group_assigned and target_group.id == previous_group_id: logger.debug( @@ -60,9 +82,15 @@ async def maybe_assign_promo_group_by_total_spent( user.telegram_id, target_group.name, ) + if target_threshold > previous_threshold: + user.auto_promo_group_threshold_kopeks = target_threshold + user.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(user) return target_group user.auto_promo_group_assigned = True + user.auto_promo_group_threshold_kopeks = target_threshold user.updated_at = datetime.utcnow() if target_group.id != previous_group_id: From e75b44695e7b110375e0f5524c5bb874abb25972 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 15:26:55 +0300 Subject: [PATCH 138/146] Fix promo group discount summary formatting --- app/handlers/admin/promo_groups.py | 84 +++++++++++++++++++----------- app/localization/locales/en.json | 7 +++ app/localization/locales/ru.json | 7 +++ locales/en.json | 7 +++ locales/ru.json | 7 +++ 5 files changed, 82 insertions(+), 30 deletions(-) diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index d21b9249..414bed79 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -3,6 +3,7 @@ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Dict, Optional, Tuple from aiogram import Dispatcher, types, F +from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -28,29 +29,44 @@ from app.utils.pricing_utils import format_period_description logger = logging.getLogger(__name__) -def _format_discount_line(texts, group) -> str: - return texts.t( - "ADMIN_PROMO_GROUPS_DISCOUNTS", - "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", - ).format( - servers=group.server_discount_percent, - traffic=group.traffic_discount_percent, - devices=group.device_discount_percent, +def _format_discount_lines(texts, group) -> Tuple[str, ...]: + header = texts.t( + "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER", + "💸 Размер скидок", ) + servers_line = texts.t( + "ADMIN_PROMO_GROUP_DISCOUNTS_SERVERS", + "• Серверы: {percent}%", + ).format(percent=group.server_discount_percent) + + traffic_line = texts.t( + "ADMIN_PROMO_GROUP_DISCOUNTS_TRAFFIC", + "• Трафик: {percent}%", + ).format(percent=group.traffic_discount_percent) + + devices_line = texts.t( + "ADMIN_PROMO_GROUP_DISCOUNTS_DEVICES", + "• Устройства: {percent}%", + ).format(percent=group.device_discount_percent) + + return header, servers_line, traffic_line, devices_line + def _format_addon_discounts_line(texts, group: PromoGroup) -> str: enabled = getattr(group, "apply_discounts_to_addons", True) - if enabled: - return texts.t( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED", - "Скидки на доп. услуги: включены", - ) - return texts.t( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED", - "Скидки на доп. услуги: отключены", + status = texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ENABLED" + if enabled + else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_DISABLED", + "включены" if enabled else "отключены", ) + return texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_LINE", + "• Доп. услуги: {status}", + ).format(status=status) + def _get_addon_discounts_button_text(texts, group: PromoGroup) -> str: enabled = getattr(group, "apply_discounts_to_addons", True) @@ -280,12 +296,10 @@ def _build_edit_menu_content( "✏️ Настройки промогруппы «{name}»", ).format(name=group.name) - lines = [ - header, - _format_discount_line(texts, group), - _format_addon_discounts_line(texts, group), - _format_auto_assign_line(texts, group), - ] + lines = [header] + lines.extend(_format_discount_lines(texts, group)) + lines.append(_format_addon_discounts_line(texts, group)) + lines.append(_format_auto_assign_line(texts, group)) period_lines = _format_period_discounts_lines(texts, group, language) lines.extend(period_lines) @@ -394,12 +408,22 @@ async def _send_edit_menu_after_update( ): menu_text, keyboard = _build_edit_menu_content(texts, group, language) parts = [part for part in [success_message, menu_text] if part] + text = "\n\n".join(parts) - await message.answer( - "\n\n".join(parts), - reply_markup=keyboard, - parse_mode="HTML", - ) + try: + await message.edit_text( + text, + reply_markup=keyboard, + parse_mode="HTML", + ) + except TelegramBadRequest as exc: + if "message is not modified" in str(exc).lower(): + return + await message.answer( + text, + reply_markup=keyboard, + parse_mode="HTML", + ) @admin_required @@ -431,7 +455,7 @@ async def show_promo_groups_menu( ) group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", - _format_discount_line(texts, group), + "\n".join(_format_discount_lines(texts, group)), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", @@ -506,7 +530,7 @@ async def show_promo_group_details( "ADMIN_PROMO_GROUP_DETAILS_TITLE", "💳 Промогруппа: {name}", ).format(name=group.name), - _format_discount_line(texts, group), + "\n".join(_format_discount_lines(texts, group)), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", @@ -1261,7 +1285,7 @@ async def toggle_promo_group_addon_discounts( status_text, ) - await callback.answer() + await callback.answer(status_text) def register_handlers(dp: Dispatcher): diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index e2aa4e2a..56ef19f6 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -140,6 +140,13 @@ "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled", + "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Discount overview", + "ADMIN_PROMO_GROUP_DISCOUNTS_SERVERS": "• Servers: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNTS_TRAFFIC": "• Traffic: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNTS_DEVICES": "• Devices: {percent}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_LINE": "• Add-on services: {status}", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ENABLED": "enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_DISABLED": "disabled", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 669987a3..655f82c4 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -17,6 +17,13 @@ "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены", + "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Размер скидок", + "ADMIN_PROMO_GROUP_DISCOUNTS_SERVERS": "• Серверы: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNTS_TRAFFIC": "• Трафик: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNTS_DEVICES": "• Устройства: {percent}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_LINE": "• Доп. услуги: {status}", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ENABLED": "включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_DISABLED": "отключены", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.", diff --git a/locales/en.json b/locales/en.json index 110c9ae9..e0fe550c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -153,6 +153,13 @@ "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled", + "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Discount overview", + "ADMIN_PROMO_GROUP_DISCOUNTS_SERVERS": "• Servers: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNTS_TRAFFIC": "• Traffic: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNTS_DEVICES": "• Devices: {percent}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_LINE": "• Add-on services: {status}", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ENABLED": "enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_DISABLED": "disabled", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.", diff --git a/locales/ru.json b/locales/ru.json index 51eab11b..9e132fe5 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -19,6 +19,13 @@ "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены", + "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Размер скидок", + "ADMIN_PROMO_GROUP_DISCOUNTS_SERVERS": "• Серверы: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNTS_TRAFFIC": "• Трафик: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNTS_DEVICES": "• Устройства: {percent}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_LINE": "• Доп. услуги: {status}", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ENABLED": "включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_DISABLED": "отключены", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.", From e8bca6926a1e43c3f9fe0b0ec0e5abab098cae54 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 15:30:00 +0300 Subject: [PATCH 139/146] Revert "Improve promo group addon discount toggle UX" --- app/handlers/admin/promo_groups.py | 84 +++++++++++------------------- app/localization/locales/en.json | 7 --- app/localization/locales/ru.json | 7 --- locales/en.json | 7 --- locales/ru.json | 7 --- 5 files changed, 30 insertions(+), 82 deletions(-) diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 414bed79..d21b9249 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -3,7 +3,6 @@ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Dict, Optional, Tuple from aiogram import Dispatcher, types, F -from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -29,43 +28,28 @@ from app.utils.pricing_utils import format_period_description logger = logging.getLogger(__name__) -def _format_discount_lines(texts, group) -> Tuple[str, ...]: - header = texts.t( - "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER", - "💸 Размер скидок", +def _format_discount_line(texts, group) -> str: + return texts.t( + "ADMIN_PROMO_GROUPS_DISCOUNTS", + "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + ).format( + servers=group.server_discount_percent, + traffic=group.traffic_discount_percent, + devices=group.device_discount_percent, ) - servers_line = texts.t( - "ADMIN_PROMO_GROUP_DISCOUNTS_SERVERS", - "• Серверы: {percent}%", - ).format(percent=group.server_discount_percent) - - traffic_line = texts.t( - "ADMIN_PROMO_GROUP_DISCOUNTS_TRAFFIC", - "• Трафик: {percent}%", - ).format(percent=group.traffic_discount_percent) - - devices_line = texts.t( - "ADMIN_PROMO_GROUP_DISCOUNTS_DEVICES", - "• Устройства: {percent}%", - ).format(percent=group.device_discount_percent) - - return header, servers_line, traffic_line, devices_line - def _format_addon_discounts_line(texts, group: PromoGroup) -> str: enabled = getattr(group, "apply_discounts_to_addons", True) - status = texts.t( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ENABLED" - if enabled - else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_DISABLED", - "включены" if enabled else "отключены", - ) - + if enabled: + return texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED", + "Скидки на доп. услуги: включены", + ) return texts.t( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_LINE", - "• Доп. услуги: {status}", - ).format(status=status) + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED", + "Скидки на доп. услуги: отключены", + ) def _get_addon_discounts_button_text(texts, group: PromoGroup) -> str: @@ -296,10 +280,12 @@ def _build_edit_menu_content( "✏️ Настройки промогруппы «{name}»", ).format(name=group.name) - lines = [header] - lines.extend(_format_discount_lines(texts, group)) - lines.append(_format_addon_discounts_line(texts, group)) - lines.append(_format_auto_assign_line(texts, group)) + lines = [ + header, + _format_discount_line(texts, group), + _format_addon_discounts_line(texts, group), + _format_auto_assign_line(texts, group), + ] period_lines = _format_period_discounts_lines(texts, group, language) lines.extend(period_lines) @@ -408,22 +394,12 @@ async def _send_edit_menu_after_update( ): menu_text, keyboard = _build_edit_menu_content(texts, group, language) parts = [part for part in [success_message, menu_text] if part] - text = "\n\n".join(parts) - try: - await message.edit_text( - text, - reply_markup=keyboard, - parse_mode="HTML", - ) - except TelegramBadRequest as exc: - if "message is not modified" in str(exc).lower(): - return - await message.answer( - text, - reply_markup=keyboard, - parse_mode="HTML", - ) + await message.answer( + "\n\n".join(parts), + reply_markup=keyboard, + parse_mode="HTML", + ) @admin_required @@ -455,7 +431,7 @@ async def show_promo_groups_menu( ) group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", - "\n".join(_format_discount_lines(texts, group)), + _format_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", @@ -530,7 +506,7 @@ async def show_promo_group_details( "ADMIN_PROMO_GROUP_DETAILS_TITLE", "💳 Промогруппа: {name}", ).format(name=group.name), - "\n".join(_format_discount_lines(texts, group)), + _format_discount_line(texts, group), _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", @@ -1285,7 +1261,7 @@ async def toggle_promo_group_addon_discounts( status_text, ) - await callback.answer(status_text) + await callback.answer() def register_handlers(dp: Dispatcher): diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 56ef19f6..e2aa4e2a 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -140,13 +140,6 @@ "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled", - "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Discount overview", - "ADMIN_PROMO_GROUP_DISCOUNTS_SERVERS": "• Servers: {percent}%", - "ADMIN_PROMO_GROUP_DISCOUNTS_TRAFFIC": "• Traffic: {percent}%", - "ADMIN_PROMO_GROUP_DISCOUNTS_DEVICES": "• Devices: {percent}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_LINE": "• Add-on services: {status}", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ENABLED": "enabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_DISABLED": "disabled", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 655f82c4..669987a3 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -17,13 +17,6 @@ "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены", - "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Размер скидок", - "ADMIN_PROMO_GROUP_DISCOUNTS_SERVERS": "• Серверы: {percent}%", - "ADMIN_PROMO_GROUP_DISCOUNTS_TRAFFIC": "• Трафик: {percent}%", - "ADMIN_PROMO_GROUP_DISCOUNTS_DEVICES": "• Устройства: {percent}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_LINE": "• Доп. услуги: {status}", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ENABLED": "включены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_DISABLED": "отключены", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.", diff --git a/locales/en.json b/locales/en.json index e0fe550c..110c9ae9 100644 --- a/locales/en.json +++ b/locales/en.json @@ -153,13 +153,6 @@ "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled", - "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Discount overview", - "ADMIN_PROMO_GROUP_DISCOUNTS_SERVERS": "• Servers: {percent}%", - "ADMIN_PROMO_GROUP_DISCOUNTS_TRAFFIC": "• Traffic: {percent}%", - "ADMIN_PROMO_GROUP_DISCOUNTS_DEVICES": "• Devices: {percent}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_LINE": "• Add-on services: {status}", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ENABLED": "enabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_DISABLED": "disabled", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.", diff --git a/locales/ru.json b/locales/ru.json index 9e132fe5..51eab11b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -19,13 +19,6 @@ "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены", - "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Размер скидок", - "ADMIN_PROMO_GROUP_DISCOUNTS_SERVERS": "• Серверы: {percent}%", - "ADMIN_PROMO_GROUP_DISCOUNTS_TRAFFIC": "• Трафик: {percent}%", - "ADMIN_PROMO_GROUP_DISCOUNTS_DEVICES": "• Устройства: {percent}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNTS_LINE": "• Доп. услуги: {status}", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_ENABLED": "включены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_STATUS_DISABLED": "отключены", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.", From d8571c4f3ea188c7f17ea38e8f065c6dd8d16033 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 15:30:30 +0300 Subject: [PATCH 140/146] Improve promo group discount UI and toggle handling --- app/handlers/admin/promo_groups.py | 86 ++++++++++++++++++++---------- app/localization/locales/en.json | 13 +++-- app/localization/locales/ru.json | 13 +++-- locales/en.json | 13 +++-- locales/ru.json | 13 +++-- 5 files changed, 89 insertions(+), 49 deletions(-) diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index d21b9249..1cb1df73 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -3,6 +3,7 @@ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Dict, Optional, Tuple from aiogram import Dispatcher, types, F +from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -28,15 +29,25 @@ from app.utils.pricing_utils import format_period_description logger = logging.getLogger(__name__) -def _format_discount_line(texts, group) -> str: - return texts.t( - "ADMIN_PROMO_GROUPS_DISCOUNTS", - "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", - ).format( - servers=group.server_discount_percent, - traffic=group.traffic_discount_percent, - devices=group.device_discount_percent, - ) +def _format_discount_lines(texts, group) -> list[str]: + return [ + texts.t( + "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER", + "💸 Скидки промогруппы:", + ), + texts.t( + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS", + "• Серверы: {percent}%", + ).format(percent=group.server_discount_percent), + texts.t( + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC", + "• Трафик: {percent}%", + ).format(percent=group.traffic_discount_percent), + texts.t( + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES", + "• Устройства: {percent}%", + ).format(percent=group.device_discount_percent), + ] def _format_addon_discounts_line(texts, group: PromoGroup) -> str: @@ -44,11 +55,11 @@ def _format_addon_discounts_line(texts, group: PromoGroup) -> str: if enabled: return texts.t( "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED", - "Скидки на доп. услуги: включены", + "🧩 Скидки на доп. услуги: включены", ) return texts.t( "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED", - "Скидки на доп. услуги: отключены", + "🧩 Скидки на доп. услуги: отключены", ) @@ -280,12 +291,10 @@ def _build_edit_menu_content( "✏️ Настройки промогруппы «{name}»", ).format(name=group.name) - lines = [ - header, - _format_discount_line(texts, group), - _format_addon_discounts_line(texts, group), - _format_auto_assign_line(texts, group), - ] + lines = [header] + lines.extend(_format_discount_lines(texts, group)) + lines.append(_format_addon_discounts_line(texts, group)) + lines.append(_format_auto_assign_line(texts, group)) period_lines = _format_period_discounts_lines(texts, group, language) lines.extend(period_lines) @@ -395,8 +404,23 @@ async def _send_edit_menu_after_update( menu_text, keyboard = _build_edit_menu_content(texts, group, language) parts = [part for part in [success_message, menu_text] if part] + text = "\n\n".join(parts) + + from_user = getattr(message, "from_user", None) + + if getattr(from_user, "is_bot", False): + try: + await message.edit_text( + text, + reply_markup=keyboard, + parse_mode="HTML", + ) + return + except TelegramBadRequest: + pass + await message.answer( - "\n\n".join(parts), + text, reply_markup=keyboard, parse_mode="HTML", ) @@ -431,13 +455,15 @@ async def show_promo_groups_menu( ) group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", - _format_discount_line(texts, group), - _format_auto_assign_line(texts, group), + ] + group_lines.extend(_format_discount_lines(texts, group)) + group_lines.append(_format_auto_assign_line(texts, group)) + group_lines.append( texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", "Участников: {count}", - ).format(count=member_count), - ] + ).format(count=member_count) + ) period_lines = _format_period_discounts_lines(texts, group, db_user.language) group_lines.extend(period_lines) @@ -505,14 +531,16 @@ async def show_promo_group_details( texts.t( "ADMIN_PROMO_GROUP_DETAILS_TITLE", "💳 Промогруппа: {name}", - ).format(name=group.name), - _format_discount_line(texts, group), - _format_auto_assign_line(texts, group), + ).format(name=group.name) + ] + lines.extend(_format_discount_lines(texts, group)) + lines.append(_format_auto_assign_line(texts, group)) + lines.append( texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", "Участников: {count}", - ).format(count=member_count), - ] + ).format(count=member_count) + ) period_lines = _format_period_discounts_lines(texts, group, db_user.language) lines.extend(period_lines) @@ -1250,8 +1278,8 @@ async def toggle_promo_group_addon_discounts( "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED" if new_value else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED", - "Скидки на докупку доп. услуг {status}.", - ).format(status="включены" if new_value else "отключены") + "🧩 Скидки на докупку доп. услуг {status}.", + ).format(status="включены" if new_value else "отключены") await _send_edit_menu_after_update( callback.message, diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index e2aa4e2a..cdd32cf7 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -137,13 +137,16 @@ "ADMIN_PROMO_GROUPS": "💳 Promo groups", "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled", + "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Promo group discounts:", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Servers: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Traffic: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Devices: {percent}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Add-on discounts: enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Add-on discounts: disabled", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Add-on purchase discounts have been disabled.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Add-on purchase discounts are enabled.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Add-on purchase discounts are disabled.", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 669987a3..290685ad 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -14,13 +14,16 @@ "ADMIN_PROMO_GROUPS": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены", + "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Скидки промогруппы:", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Серверы: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Трафик: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Устройства: {percent}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Скидки на доп. услуги: включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Скидки на доп. услуги: отключены", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Скидки на докупку доп. услуг отключены.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Скидки на докупку доп. услуг включены.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Скидки на докупку доп. услуг отключены.", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", diff --git a/locales/en.json b/locales/en.json index 110c9ae9..57e44987 100644 --- a/locales/en.json +++ b/locales/en.json @@ -150,13 +150,16 @@ "ADMIN_PROMO_GROUPS": "💳 Promo groups", "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled", + "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Promo group discounts:", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Servers: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Traffic: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Devices: {percent}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Add-on discounts: enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Add-on discounts: disabled", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Add-on purchase discounts have been disabled.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Add-on purchase discounts are enabled.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Add-on purchase discounts are disabled.", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", diff --git a/locales/ru.json b/locales/ru.json index 51eab11b..49f1ab43 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -16,13 +16,16 @@ "ADMIN_PROMO_GROUPS": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", - "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены", + "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Скидки промогруппы:", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Серверы: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Трафик: {percent}%", + "ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Устройства: {percent}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Скидки на доп. услуги: включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Скидки на доп. услуги: отключены", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.", - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Скидки на докупку доп. услуг отключены.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Скидки на докупку доп. услуг включены.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Скидки на докупку доп. услуг отключены.", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", From 6d2ce895e4058d1831aa242d346e9523deef12e5 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 15:48:40 +0300 Subject: [PATCH 141/146] Add trial subscription channel membership monitoring --- app/services/monitoring_service.py | 133 +++++++++++++++++++++++++++ app/services/subscription_service.py | 18 +++- 2 files changed, 148 insertions(+), 3 deletions(-) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 966842ef..e6a0c6f4 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Dict, List, Any, Optional, Set +from aiogram.enums import ChatMemberStatus from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.types import FSInputFile from sqlalchemy import select, and_, or_ @@ -181,6 +182,7 @@ class MonitoringService: await self._check_expiring_subscriptions(db) await self._check_trial_expiring_soon(db) await self._check_trial_inactivity_notifications(db) + await self._check_trial_channel_membership(db) await self._check_expired_subscription_followups(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) @@ -456,6 +458,137 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}") + async def _check_trial_channel_membership(self, db: AsyncSession): + if not self.bot: + return + + if not settings.CHANNEL_IS_REQUIRED_SUB: + return + + channel_id = settings.CHANNEL_SUB_ID + if not channel_id: + return + + try: + now = datetime.utcnow() + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.is_trial == True, + Subscription.end_date > now, + Subscription.status.in_([ + SubscriptionStatus.ACTIVE.value, + SubscriptionStatus.DISABLED.value, + SubscriptionStatus.TRIAL.value, + ]), + ) + ) + ) + subscriptions = result.scalars().all() + except Exception as db_error: + logger.error(f"Ошибка выборки триальных подписок для проверки канала: {db_error}") + return + + if not subscriptions: + return + + good_statuses = { + ChatMemberStatus.MEMBER, + ChatMemberStatus.ADMINISTRATOR, + ChatMemberStatus.CREATOR, + } + bad_statuses = { + ChatMemberStatus.LEFT, + ChatMemberStatus.KICKED, + ChatMemberStatus.RESTRICTED, + } + + disabled_count = 0 + reenabled_count = 0 + + for subscription in subscriptions: + user = subscription.user + if not user or not user.telegram_id: + continue + + try: + member = await self.bot.get_chat_member(channel_id, user.telegram_id) + except TelegramForbiddenError as e: + logger.error(f"Бот не имеет доступа к каналу {channel_id}: {e}") + return + except TelegramBadRequest as e: + message = str(e).lower() + if "chat not found" in message: + logger.error(f"Канал {channel_id} не найден при проверке триальных подписок: {e}") + return + logger.warning(f"Не удалось получить статус пользователя {user.telegram_id} в канале: {e}") + continue + except Exception as e: + logger.error(f"Неожиданная ошибка при проверке подписки пользователя {user.telegram_id}: {e}") + continue + + member_status = getattr(member, "status", None) + + if member_status in good_statuses: + if subscription.status == SubscriptionStatus.DISABLED.value: + if subscription.end_date <= now: + continue + + subscription.status = SubscriptionStatus.ACTIVE.value + subscription.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(subscription) + + if user.remnawave_uuid: + await self.subscription_service.enable_remnawave_user(user.remnawave_uuid) + await self.subscription_service.update_remnawave_user(db, subscription) + + reenabled_count += 1 + logger.info( + "🎯 Тестовая подписка пользователя %s повторно активирована после возвращения в канал", + user.telegram_id, + ) + + elif member_status in bad_statuses: + if subscription.status != SubscriptionStatus.DISABLED.value: + subscription.status = SubscriptionStatus.DISABLED.value + subscription.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(subscription) + + if user.remnawave_uuid: + await self.subscription_service.disable_remnawave_user(user.remnawave_uuid) + + disabled_count += 1 + logger.info( + "🚫 Тестовая подписка пользователя %s отключена из-за отписки от канала", + user.telegram_id, + ) + + else: + logger.debug( + "⚠️ Получен непредвиденный статус %s для пользователя %s при проверке канала", + member_status, + user.telegram_id, + ) + + if disabled_count or reenabled_count: + await self._log_monitoring_event( + db, + "trial_channel_membership_check", + ( + f"Отключено {disabled_count} тестовых подписок из-за отписки и " + f"повторно активировано {reenabled_count}" + ), + { + "disabled": disabled_count, + "reenabled": reenabled_count, + "channel_id": channel_id, + }, + ) + async def _check_expired_subscription_followups(self, db: AsyncSession): if not NotificationSettingsService.are_notifications_globally_enabled(): return diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 54ba6720..64d06626 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -228,17 +228,29 @@ class SubscriptionService: return None async def disable_remnawave_user(self, user_uuid: str) -> bool: - + try: async with self.api as api: await api.disable_user(user_uuid) logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}") return True - + except Exception as e: logger.error(f"Ошибка отключения RemnaWave пользователя: {e}") return False - + + async def enable_remnawave_user(self, user_uuid: str) -> bool: + + try: + async with self.api as api: + await api.enable_user(user_uuid) + logger.info(f"✅ Включен RemnaWave пользователь {user_uuid}") + return True + + except Exception as e: + logger.error(f"Ошибка включения RemnaWave пользователя: {e}") + return False + async def revoke_subscription( self, db: AsyncSession, From 41ab0abac8f253faf7363b2bde81770beedc6dcd Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 15:50:49 +0300 Subject: [PATCH 142/146] Revert "Add trial subscription channel membership monitoring" --- app/services/monitoring_service.py | 133 --------------------------- app/services/subscription_service.py | 18 +--- 2 files changed, 3 insertions(+), 148 deletions(-) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index e6a0c6f4..966842ef 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -4,7 +4,6 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Dict, List, Any, Optional, Set -from aiogram.enums import ChatMemberStatus from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.types import FSInputFile from sqlalchemy import select, and_, or_ @@ -182,7 +181,6 @@ class MonitoringService: await self._check_expiring_subscriptions(db) await self._check_trial_expiring_soon(db) await self._check_trial_inactivity_notifications(db) - await self._check_trial_channel_membership(db) await self._check_expired_subscription_followups(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) @@ -458,137 +456,6 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}") - async def _check_trial_channel_membership(self, db: AsyncSession): - if not self.bot: - return - - if not settings.CHANNEL_IS_REQUIRED_SUB: - return - - channel_id = settings.CHANNEL_SUB_ID - if not channel_id: - return - - try: - now = datetime.utcnow() - result = await db.execute( - select(Subscription) - .options(selectinload(Subscription.user)) - .where( - and_( - Subscription.is_trial == True, - Subscription.end_date > now, - Subscription.status.in_([ - SubscriptionStatus.ACTIVE.value, - SubscriptionStatus.DISABLED.value, - SubscriptionStatus.TRIAL.value, - ]), - ) - ) - ) - subscriptions = result.scalars().all() - except Exception as db_error: - logger.error(f"Ошибка выборки триальных подписок для проверки канала: {db_error}") - return - - if not subscriptions: - return - - good_statuses = { - ChatMemberStatus.MEMBER, - ChatMemberStatus.ADMINISTRATOR, - ChatMemberStatus.CREATOR, - } - bad_statuses = { - ChatMemberStatus.LEFT, - ChatMemberStatus.KICKED, - ChatMemberStatus.RESTRICTED, - } - - disabled_count = 0 - reenabled_count = 0 - - for subscription in subscriptions: - user = subscription.user - if not user or not user.telegram_id: - continue - - try: - member = await self.bot.get_chat_member(channel_id, user.telegram_id) - except TelegramForbiddenError as e: - logger.error(f"Бот не имеет доступа к каналу {channel_id}: {e}") - return - except TelegramBadRequest as e: - message = str(e).lower() - if "chat not found" in message: - logger.error(f"Канал {channel_id} не найден при проверке триальных подписок: {e}") - return - logger.warning(f"Не удалось получить статус пользователя {user.telegram_id} в канале: {e}") - continue - except Exception as e: - logger.error(f"Неожиданная ошибка при проверке подписки пользователя {user.telegram_id}: {e}") - continue - - member_status = getattr(member, "status", None) - - if member_status in good_statuses: - if subscription.status == SubscriptionStatus.DISABLED.value: - if subscription.end_date <= now: - continue - - subscription.status = SubscriptionStatus.ACTIVE.value - subscription.updated_at = datetime.utcnow() - await db.commit() - await db.refresh(subscription) - - if user.remnawave_uuid: - await self.subscription_service.enable_remnawave_user(user.remnawave_uuid) - await self.subscription_service.update_remnawave_user(db, subscription) - - reenabled_count += 1 - logger.info( - "🎯 Тестовая подписка пользователя %s повторно активирована после возвращения в канал", - user.telegram_id, - ) - - elif member_status in bad_statuses: - if subscription.status != SubscriptionStatus.DISABLED.value: - subscription.status = SubscriptionStatus.DISABLED.value - subscription.updated_at = datetime.utcnow() - await db.commit() - await db.refresh(subscription) - - if user.remnawave_uuid: - await self.subscription_service.disable_remnawave_user(user.remnawave_uuid) - - disabled_count += 1 - logger.info( - "🚫 Тестовая подписка пользователя %s отключена из-за отписки от канала", - user.telegram_id, - ) - - else: - logger.debug( - "⚠️ Получен непредвиденный статус %s для пользователя %s при проверке канала", - member_status, - user.telegram_id, - ) - - if disabled_count or reenabled_count: - await self._log_monitoring_event( - db, - "trial_channel_membership_check", - ( - f"Отключено {disabled_count} тестовых подписок из-за отписки и " - f"повторно активировано {reenabled_count}" - ), - { - "disabled": disabled_count, - "reenabled": reenabled_count, - "channel_id": channel_id, - }, - ) - async def _check_expired_subscription_followups(self, db: AsyncSession): if not NotificationSettingsService.are_notifications_globally_enabled(): return diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 64d06626..54ba6720 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -228,29 +228,17 @@ class SubscriptionService: return None async def disable_remnawave_user(self, user_uuid: str) -> bool: - + try: async with self.api as api: await api.disable_user(user_uuid) logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}") return True - + except Exception as e: logger.error(f"Ошибка отключения RemnaWave пользователя: {e}") return False - - async def enable_remnawave_user(self, user_uuid: str) -> bool: - - try: - async with self.api as api: - await api.enable_user(user_uuid) - logger.info(f"✅ Включен RemnaWave пользователь {user_uuid}") - return True - - except Exception as e: - logger.error(f"Ошибка включения RemnaWave пользователя: {e}") - return False - + async def revoke_subscription( self, db: AsyncSession, From 93200d14505666c5cebefe4a6962205b8133ab67 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 15:51:13 +0300 Subject: [PATCH 143/146] Add trial channel subscription monitoring --- app/database/crud/subscription.py | 63 +++++++++++++- app/services/monitoring_service.py | 131 ++++++++++++++++++++++++++++- 2 files changed, 189 insertions(+), 5 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 91b79375..612ab943 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -226,22 +226,39 @@ async def deactivate_subscription( db: AsyncSession, subscription: Subscription ) -> Subscription: - + subscription.status = SubscriptionStatus.DISABLED.value subscription.updated_at = datetime.utcnow() - + await db.commit() await db.refresh(subscription) - + logger.info(f"❌ Подписка пользователя {subscription.user_id} деактивирована") return subscription +async def activate_subscription( + db: AsyncSession, + subscription: Subscription +) -> Subscription: + + if subscription.status != SubscriptionStatus.ACTIVE.value: + subscription.status = SubscriptionStatus.ACTIVE.value + subscription.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(subscription) + + logger.info(f"✅ Подписка пользователя {subscription.user_id} активирована") + + return subscription + + async def get_expiring_subscriptions( db: AsyncSession, days_before: int = 3 ) -> List[Subscription]: - + threshold_date = datetime.utcnow() + timedelta(days=days_before) result = await db.execute( @@ -273,6 +290,44 @@ async def get_expired_subscriptions(db: AsyncSession) -> List[Subscription]: return result.scalars().all() +async def get_active_trial_subscriptions(db: AsyncSession) -> List[Subscription]: + + current_time = datetime.utcnow() + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.is_trial == True, + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.end_date > current_time, + ) + ) + ) + + return result.scalars().all() + + +async def get_disabled_trial_subscriptions(db: AsyncSession) -> List[Subscription]: + + current_time = datetime.utcnow() + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.is_trial == True, + Subscription.status == SubscriptionStatus.DISABLED.value, + Subscription.end_date > current_time, + ) + ) + ) + + return result.scalars().all() + + async def get_subscriptions_for_autopay(db: AsyncSession) -> List[Subscription]: current_time = datetime.utcnow() diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 966842ef..264859e1 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Dict, List, Any, Optional, Set +from aiogram.enums import ChatMemberStatus from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.types import FSInputFile from sqlalchemy import select, and_, or_ @@ -21,8 +22,11 @@ from app.database.crud.notification import ( record_notification, ) from app.database.crud.subscription import ( + activate_subscription, deactivate_subscription, extend_subscription, + get_active_trial_subscriptions, + get_disabled_trial_subscriptions, get_expired_subscriptions, get_expiring_subscriptions, get_subscriptions_for_autopay, @@ -33,7 +37,15 @@ from app.database.crud.user import ( get_user_by_id, subtract_user_balance, ) -from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus +from app.database.models import ( + MonitoringLog, + SubscriptionStatus, + Subscription, + User, + Ticket, + TicketStatus, + UserStatus, +) from app.localization.texts import get_texts from app.services.notification_settings_service import NotificationSettingsService from app.services.payment_service import PaymentService @@ -181,6 +193,7 @@ class MonitoringService: await self._check_expiring_subscriptions(db) await self._check_trial_expiring_soon(db) await self._check_trial_inactivity_notifications(db) + await self._check_trial_channel_subscriptions(db) await self._check_expired_subscription_followups(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) @@ -456,6 +469,122 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}") + async def _check_trial_channel_subscriptions(self, db: AsyncSession): + if not self.bot: + logger.debug("Пропускаем проверку подписки на канал — bot не инициализирован") + return + + channel_id = settings.CHANNEL_SUB_ID + + if not channel_id or not settings.CHANNEL_IS_REQUIRED_SUB: + logger.debug("Пропускаем проверку подписки на канал — настройка отключена") + return + + try: + active_trials = await get_active_trial_subscriptions(db) + disabled_trials = await get_disabled_trial_subscriptions(db) + + if not active_trials and not disabled_trials: + return + + good_statuses = { + ChatMemberStatus.MEMBER, + ChatMemberStatus.ADMINISTRATOR, + ChatMemberStatus.CREATOR, + } + bad_statuses = { + ChatMemberStatus.LEFT, + ChatMemberStatus.KICKED, + ChatMemberStatus.RESTRICTED, + } + + async def fetch_member_status(user: User) -> Optional[ChatMemberStatus]: + try: + member = await self.bot.get_chat_member(channel_id, user.telegram_id) + return member.status + except TelegramBadRequest as exc: + message = str(exc).lower() + if "user not found" in message: + return ChatMemberStatus.LEFT + if "chat not found" in message: + logger.error(f"❌ Канал {channel_id} не найден: {exc}") + return None + logger.error( + "❌ Ошибка проверки подписки пользователя %s: %s", + user.telegram_id, + exc, + ) + return None + except TelegramForbiddenError as exc: + logger.error(f"❌ Бот не имеет доступа к каналу {channel_id}: {exc}") + return None + except Exception as exc: + logger.error( + "❌ Неожиданная ошибка при проверке подписки пользователя %s: %s", + user.telegram_id, + exc, + ) + return None + + disabled_count = 0 + reactivated_count = 0 + + for subscription in active_trials: + user = subscription.user + if not user or user.status != UserStatus.ACTIVE.value: + continue + + status = await fetch_member_status(user) + + if status in bad_statuses: + await deactivate_subscription(db, subscription) + disabled_count += 1 + + if user.remnawave_uuid: + await self.subscription_service.disable_remnawave_user(user.remnawave_uuid) + + for subscription in disabled_trials: + user = subscription.user + if not user or user.status != UserStatus.ACTIVE.value: + continue + + status = await fetch_member_status(user) + + if status in good_statuses: + updated_subscription = await activate_subscription(db, subscription) + reactivated_count += 1 + + if user.remnawave_uuid: + await self.subscription_service.update_remnawave_user(db, updated_subscription) + + if disabled_count or reactivated_count: + logger.info( + "🔄 Проверка подписки на канал: деактивировано %s, восстановлено %s", + disabled_count, + reactivated_count, + ) + await self._log_monitoring_event( + db, + "trial_channel_subscription_check", + "Проверка подписки на канал для триальных пользователей", + { + "disabled": disabled_count, + "reactivated": reactivated_count, + "checked_active": len(active_trials), + "checked_disabled": len(disabled_trials), + }, + ) + + except Exception as exc: + logger.error(f"Ошибка проверки подписки на канал: {exc}") + await self._log_monitoring_event( + db, + "trial_channel_subscription_error", + f"Ошибка проверки подписки на канал: {str(exc)}", + {"error": str(exc)}, + is_success=False, + ) + async def _check_expired_subscription_followups(self, db: AsyncSession): if not NotificationSettingsService.are_notifications_globally_enabled(): return From 3ff8cb5e61c5020f8c4fa366ca03b3ba218abcad Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 15:53:51 +0300 Subject: [PATCH 144/146] Revert "Add trial channel subscription monitoring" --- app/database/crud/subscription.py | 63 +------------- app/services/monitoring_service.py | 131 +---------------------------- 2 files changed, 5 insertions(+), 189 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 612ab943..91b79375 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -226,39 +226,22 @@ async def deactivate_subscription( db: AsyncSession, subscription: Subscription ) -> Subscription: - + subscription.status = SubscriptionStatus.DISABLED.value subscription.updated_at = datetime.utcnow() - + await db.commit() await db.refresh(subscription) - + logger.info(f"❌ Подписка пользователя {subscription.user_id} деактивирована") return subscription -async def activate_subscription( - db: AsyncSession, - subscription: Subscription -) -> Subscription: - - if subscription.status != SubscriptionStatus.ACTIVE.value: - subscription.status = SubscriptionStatus.ACTIVE.value - subscription.updated_at = datetime.utcnow() - - await db.commit() - await db.refresh(subscription) - - logger.info(f"✅ Подписка пользователя {subscription.user_id} активирована") - - return subscription - - async def get_expiring_subscriptions( db: AsyncSession, days_before: int = 3 ) -> List[Subscription]: - + threshold_date = datetime.utcnow() + timedelta(days=days_before) result = await db.execute( @@ -290,44 +273,6 @@ async def get_expired_subscriptions(db: AsyncSession) -> List[Subscription]: return result.scalars().all() -async def get_active_trial_subscriptions(db: AsyncSession) -> List[Subscription]: - - current_time = datetime.utcnow() - - result = await db.execute( - select(Subscription) - .options(selectinload(Subscription.user)) - .where( - and_( - Subscription.is_trial == True, - Subscription.status == SubscriptionStatus.ACTIVE.value, - Subscription.end_date > current_time, - ) - ) - ) - - return result.scalars().all() - - -async def get_disabled_trial_subscriptions(db: AsyncSession) -> List[Subscription]: - - current_time = datetime.utcnow() - - result = await db.execute( - select(Subscription) - .options(selectinload(Subscription.user)) - .where( - and_( - Subscription.is_trial == True, - Subscription.status == SubscriptionStatus.DISABLED.value, - Subscription.end_date > current_time, - ) - ) - ) - - return result.scalars().all() - - async def get_subscriptions_for_autopay(db: AsyncSession) -> List[Subscription]: current_time = datetime.utcnow() diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 264859e1..966842ef 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -4,7 +4,6 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Dict, List, Any, Optional, Set -from aiogram.enums import ChatMemberStatus from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.types import FSInputFile from sqlalchemy import select, and_, or_ @@ -22,11 +21,8 @@ from app.database.crud.notification import ( record_notification, ) from app.database.crud.subscription import ( - activate_subscription, deactivate_subscription, extend_subscription, - get_active_trial_subscriptions, - get_disabled_trial_subscriptions, get_expired_subscriptions, get_expiring_subscriptions, get_subscriptions_for_autopay, @@ -37,15 +33,7 @@ from app.database.crud.user import ( get_user_by_id, subtract_user_balance, ) -from app.database.models import ( - MonitoringLog, - SubscriptionStatus, - Subscription, - User, - Ticket, - TicketStatus, - UserStatus, -) +from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus from app.localization.texts import get_texts from app.services.notification_settings_service import NotificationSettingsService from app.services.payment_service import PaymentService @@ -193,7 +181,6 @@ class MonitoringService: await self._check_expiring_subscriptions(db) await self._check_trial_expiring_soon(db) await self._check_trial_inactivity_notifications(db) - await self._check_trial_channel_subscriptions(db) await self._check_expired_subscription_followups(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) @@ -469,122 +456,6 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}") - async def _check_trial_channel_subscriptions(self, db: AsyncSession): - if not self.bot: - logger.debug("Пропускаем проверку подписки на канал — bot не инициализирован") - return - - channel_id = settings.CHANNEL_SUB_ID - - if not channel_id or not settings.CHANNEL_IS_REQUIRED_SUB: - logger.debug("Пропускаем проверку подписки на канал — настройка отключена") - return - - try: - active_trials = await get_active_trial_subscriptions(db) - disabled_trials = await get_disabled_trial_subscriptions(db) - - if not active_trials and not disabled_trials: - return - - good_statuses = { - ChatMemberStatus.MEMBER, - ChatMemberStatus.ADMINISTRATOR, - ChatMemberStatus.CREATOR, - } - bad_statuses = { - ChatMemberStatus.LEFT, - ChatMemberStatus.KICKED, - ChatMemberStatus.RESTRICTED, - } - - async def fetch_member_status(user: User) -> Optional[ChatMemberStatus]: - try: - member = await self.bot.get_chat_member(channel_id, user.telegram_id) - return member.status - except TelegramBadRequest as exc: - message = str(exc).lower() - if "user not found" in message: - return ChatMemberStatus.LEFT - if "chat not found" in message: - logger.error(f"❌ Канал {channel_id} не найден: {exc}") - return None - logger.error( - "❌ Ошибка проверки подписки пользователя %s: %s", - user.telegram_id, - exc, - ) - return None - except TelegramForbiddenError as exc: - logger.error(f"❌ Бот не имеет доступа к каналу {channel_id}: {exc}") - return None - except Exception as exc: - logger.error( - "❌ Неожиданная ошибка при проверке подписки пользователя %s: %s", - user.telegram_id, - exc, - ) - return None - - disabled_count = 0 - reactivated_count = 0 - - for subscription in active_trials: - user = subscription.user - if not user or user.status != UserStatus.ACTIVE.value: - continue - - status = await fetch_member_status(user) - - if status in bad_statuses: - await deactivate_subscription(db, subscription) - disabled_count += 1 - - if user.remnawave_uuid: - await self.subscription_service.disable_remnawave_user(user.remnawave_uuid) - - for subscription in disabled_trials: - user = subscription.user - if not user or user.status != UserStatus.ACTIVE.value: - continue - - status = await fetch_member_status(user) - - if status in good_statuses: - updated_subscription = await activate_subscription(db, subscription) - reactivated_count += 1 - - if user.remnawave_uuid: - await self.subscription_service.update_remnawave_user(db, updated_subscription) - - if disabled_count or reactivated_count: - logger.info( - "🔄 Проверка подписки на канал: деактивировано %s, восстановлено %s", - disabled_count, - reactivated_count, - ) - await self._log_monitoring_event( - db, - "trial_channel_subscription_check", - "Проверка подписки на канал для триальных пользователей", - { - "disabled": disabled_count, - "reactivated": reactivated_count, - "checked_active": len(active_trials), - "checked_disabled": len(disabled_trials), - }, - ) - - except Exception as exc: - logger.error(f"Ошибка проверки подписки на канал: {exc}") - await self._log_monitoring_event( - db, - "trial_channel_subscription_error", - f"Ошибка проверки подписки на канал: {str(exc)}", - {"error": str(exc)}, - is_success=False, - ) - async def _check_expired_subscription_followups(self, db: AsyncSession): if not NotificationSettingsService.are_notifications_globally_enabled(): return From 25a4be076b2085dd2b17b8dd8d82314cebf27dd8 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 15:54:11 +0300 Subject: [PATCH 145/146] Check trial channel subscription status --- app/services/monitoring_service.py | 141 +++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 966842ef..042f67b8 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import Dict, List, Any, Optional, Set from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from aiogram.enums import ChatMemberStatus from aiogram.types import FSInputFile from sqlalchemy import select, and_, or_ from sqlalchemy.ext.asyncio import AsyncSession @@ -181,6 +182,7 @@ class MonitoringService: await self._check_expiring_subscriptions(db) await self._check_trial_expiring_soon(db) await self._check_trial_inactivity_notifications(db) + await self._check_trial_channel_subscriptions(db) await self._check_expired_subscription_followups(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) @@ -456,6 +458,145 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}") + async def _check_trial_channel_subscriptions(self, db: AsyncSession): + if not settings.CHANNEL_IS_REQUIRED_SUB: + return + + channel_id = settings.CHANNEL_SUB_ID + if not channel_id: + return + + if not self.bot: + logger.debug("⚠️ Пропускаем проверку подписки на канал — бот недоступен") + return + + try: + now = datetime.utcnow() + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.is_trial.is_(True), + Subscription.end_date > now, + Subscription.status.in_( + [ + SubscriptionStatus.ACTIVE.value, + SubscriptionStatus.DISABLED.value, + ] + ), + ) + ) + ) + + subscriptions = result.scalars().all() + if not subscriptions: + return + + disabled_count = 0 + restored_count = 0 + + for subscription in subscriptions: + user = subscription.user + if not user or not user.telegram_id: + continue + + try: + member = await self.bot.get_chat_member(channel_id, user.telegram_id) + member_status = member.status + is_member = member_status in ( + ChatMemberStatus.MEMBER, + ChatMemberStatus.ADMINISTRATOR, + ChatMemberStatus.CREATOR, + ) + except TelegramForbiddenError as error: + logger.error( + "❌ Не удалось проверить подписку пользователя %s на канал %s: бот заблокирован (%s)", + user.telegram_id, + channel_id, + error, + ) + continue + except TelegramBadRequest as error: + logger.error( + "❌ Ошибка Telegram при проверке подписки пользователя %s: %s", + user.telegram_id, + error, + ) + continue + except Exception as error: + logger.error( + "❌ Неожиданная ошибка при проверке подписки пользователя %s: %s", + user.telegram_id, + error, + ) + continue + + if subscription.status == SubscriptionStatus.ACTIVE.value and not is_member: + subscription = await deactivate_subscription(db, subscription) + disabled_count += 1 + logger.info( + "🚫 Триальная подписка пользователя %s (ID %s) отключена из-за отписки от канала", + user.telegram_id, + subscription.id, + ) + + if user.remnawave_uuid: + try: + await self.subscription_service.disable_remnawave_user(user.remnawave_uuid) + except Exception as api_error: + logger.error( + "❌ Не удалось отключить пользователя RemnaWave %s: %s", + user.remnawave_uuid, + api_error, + ) + elif subscription.status == SubscriptionStatus.DISABLED.value and is_member: + subscription.status = SubscriptionStatus.ACTIVE.value + subscription.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(subscription) + restored_count += 1 + + logger.info( + "✅ Триальная подписка пользователя %s (ID %s) восстановлена после повторной подписки на канал", + user.telegram_id, + subscription.id, + ) + + try: + if user.remnawave_uuid: + await self.subscription_service.update_remnawave_user(db, subscription) + else: + await self.subscription_service.create_remnawave_user(db, subscription) + except Exception as api_error: + logger.error( + "❌ Не удалось обновить RemnaWave пользователя %s: %s", + user.telegram_id, + api_error, + ) + + if disabled_count or restored_count: + await self._log_monitoring_event( + db, + "trial_channel_subscription_check", + ( + "Проверено {total} триальных подписок: отключено {disabled}, " + "восстановлено {restored}" + ).format( + total=len(subscriptions), + disabled=disabled_count, + restored=restored_count, + ), + { + "checked": len(subscriptions), + "disabled": disabled_count, + "restored": restored_count, + }, + ) + + except Exception as error: + logger.error(f"Ошибка проверки подписки на канал для триальных пользователей: {error}") + async def _check_expired_subscription_followups(self, db: AsyncSession): if not NotificationSettingsService.are_notifications_globally_enabled(): return From 2713bdf08ed77c625bd825bac645108e826f086b Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 16:05:30 +0300 Subject: [PATCH 146/146] Handle trial subscription status on channel membership changes --- app/handlers/start.py | 31 ++++++++++++++- app/middlewares/channel_checker.py | 61 ++++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/app/handlers/start.py b/app/handlers/start.py index 801cd606..a9f63bca 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -17,7 +17,7 @@ from app.database.crud.campaign import ( get_campaign_by_start_parameter, get_campaign_by_id, ) -from app.database.models import UserStatus +from app.database.models import UserStatus, SubscriptionStatus from app.keyboards.inline import ( get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard ) @@ -25,6 +25,7 @@ from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts, get_rules from app.services.referral_service import process_referral_registration from app.services.campaign_service import AdvertisingCampaignService +from app.services.subscription_service import SubscriptionService from app.utils.user_utils import generate_unique_referral_code from app.database.crud.user_message import get_random_active_message @@ -1141,6 +1142,34 @@ async def required_sub_channel_check( show_alert=True, ) + if user and user.subscription: + subscription = user.subscription + if ( + subscription.is_trial + and subscription.status == SubscriptionStatus.DISABLED.value + ): + subscription.status = SubscriptionStatus.ACTIVE.value + subscription.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(subscription) + logger.info( + "✅ Триальная подписка пользователя %s восстановлена после подтверждения подписки на канал", + user.telegram_id, + ) + + try: + subscription_service = SubscriptionService() + if user.remnawave_uuid: + await subscription_service.update_remnawave_user(db, subscription) + else: + await subscription_service.create_remnawave_user(db, subscription) + except Exception as api_error: + logger.error( + "❌ Ошибка обновления RemnaWave при восстановлении подписки пользователя %s: %s", + user.telegram_id if user else query.from_user.id, + api_error, + ) + await query.answer( texts.t("CHANNEL_SUBSCRIBE_THANKS", "✅ Спасибо за подписку"), show_alert=True, diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index 38777cd9..65026540 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -7,10 +7,15 @@ from aiogram.types import TelegramObject, Update, Message, CallbackQuery from aiogram.enums import ChatMemberStatus from app.config import settings +from app.database.database import get_db +from app.database.crud.subscription import deactivate_subscription +from app.database.crud.user import get_user_by_telegram_id +from app.database.models import SubscriptionStatus from app.keyboards.inline import get_channel_sub_keyboard from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts from app.utils.check_reg_process import is_registration_process +from app.services.subscription_service import SubscriptionService logger = logging.getLogger(__name__) @@ -95,11 +100,14 @@ class ChannelCheckerMiddleware(BaseMiddleware): return await handler(event, data) elif member.status in self.BAD_MEMBER_STATUS: logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})") - + + if telegram_id: + await self._deactivate_trial_subscription(telegram_id) + if isinstance(event, CallbackQuery) and event.data == "sub_channel_check": await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True) - return - + return + return await self._deny_message(event, bot, channel_link) else: logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}") @@ -120,6 +128,53 @@ class ChannelCheckerMiddleware(BaseMiddleware): logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}") return await handler(event, data) + async def _deactivate_trial_subscription(self, telegram_id: int) -> None: + async for db in get_db(): + try: + user = await get_user_by_telegram_id(db, telegram_id) + if not user or not user.subscription: + logger.debug( + "⚠️ Пользователь %s отсутствует или не имеет подписки — пропускаем деактивацию", + telegram_id, + ) + break + + subscription = user.subscription + if (not subscription.is_trial or + subscription.status != SubscriptionStatus.ACTIVE.value): + logger.debug( + "ℹ️ Подписка пользователя %s не требует деактивации (trial=%s, status=%s)", + telegram_id, + subscription.is_trial, + subscription.status, + ) + break + + await deactivate_subscription(db, subscription) + logger.info( + "🚫 Триальная подписка пользователя %s отключена после отписки от канала", + telegram_id, + ) + + if user.remnawave_uuid: + service = SubscriptionService() + try: + await service.disable_remnawave_user(user.remnawave_uuid) + except Exception as api_error: + logger.error( + "❌ Не удалось отключить пользователя RemnaWave %s: %s", + user.remnawave_uuid, + api_error, + ) + except Exception as db_error: + logger.error( + "❌ Ошибка деактивации подписки пользователя %s после отписки: %s", + telegram_id, + db_error, + ) + finally: + break + @staticmethod async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str): logger.debug("🚫 Отправляем сообщение о необходимости подписки")