Compare commits
184 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab49f3cd1e | |||
| 732e8f7b8b | |||
| 9273a4e15b | |||
| d89293f76a | |||
| 174462183d | |||
| 783927be37 | |||
| 98c68dd8dc | |||
| 13585eab54 | |||
| 169336da43 | |||
| 9ef875f6bf | |||
| 3f54706a72 | |||
| 835e5e6f7f | |||
| b292442e8d | |||
| 9faaf5c963 | |||
| 117e88fe4a | |||
| 96bf08a07c | |||
| 4523403de5 | |||
| 02682e88c1 | |||
| 323b9a7e18 | |||
| 100dc3730f | |||
| 919047317a | |||
| 6ae5b59ed1 | |||
| b3123e2e85 | |||
| fbe0ca790e | |||
| 785648ba7a | |||
| 06f8728efa | |||
| 99f102a06e | |||
| d759dd3a66 | |||
| 73dc22b0b8 | |||
| 5a082f0eb8 | |||
| 073d5a119b | |||
| 2dab106bef | |||
| ec8c7b7242 | |||
| a4b96a089f | |||
| 269f8f6220 | |||
| f1beda2b89 | |||
| 6a00db32ae | |||
| 276bc5eb40 | |||
| 8678afd055 | |||
| 31340ad4a5 | |||
| 98ce47ce71 | |||
| 3462f5b3b1 | |||
| 7ec606f93c | |||
| 7d13bf47aa | |||
| 87773806fc | |||
| dad6200ad8 | |||
| c3991162c2 | |||
| 08feb1f876 | |||
| 6f15070052 | |||
| 0c4e73ab92 | |||
| 48902c4968 | |||
| 3c715577a6 | |||
| 8e67c7a97a | |||
| c938d48026 | |||
| 99a4927f8a | |||
| df5b8dcdcd | |||
| d4a1a2e64d | |||
| 5e2df269a3 | |||
| a2056e0441 | |||
| a6bbd8f8fb | |||
| 9f4e0cf5f8 | |||
| c7e8365d3f | |||
| 49b10b490e | |||
| f26bca8627 | |||
| df139b3d34 | |||
| 05d1edf278 | |||
| a518df8ff0 | |||
| 0a13fe4f40 | |||
| 6410bd1085 | |||
| 55c4549cff | |||
| e898948073 | |||
| 5f1c8d8ac0 | |||
| 8d1f8f2355 | |||
| fbbbad608b | |||
| 03fc4abd2a | |||
| f280a37818 | |||
| f100ec62d4 | |||
| cd827612d9 | |||
| 50da2f82c8 | |||
| 4c814ec5f4 | |||
| fb8c02d5ab | |||
| 465ad3d1ad | |||
| 2bfaab7d16 | |||
| e356542df7 | |||
| a3dfbdce3b | |||
| a666e8d248 | |||
| 9ef8df22a8 | |||
| a1471c8b9a | |||
| a99a14922c | |||
| ce954ad775 | |||
| efba7c10d0 | |||
| cd10c131b2 | |||
| 3e4cb8dfa2 | |||
| 6471ab547c | |||
| c10bf07124 | |||
| 4e05882522 | |||
| 061b292d92 | |||
| 779a6c309a | |||
| c79c71f209 | |||
| d4dbce5606 | |||
| e53d99223d | |||
| ec7274e0c9 | |||
| 8fe631a98e | |||
| bd831bd92b | |||
| 9b31ba8f3d | |||
| 3759be333a | |||
| 555894b26c | |||
| bb636c003c | |||
| 40c7b4aebd | |||
| 028bd4888b | |||
| 27d64e361a | |||
| 6fc7dd49dd | |||
| b835ba1d65 | |||
| 811e985087 | |||
| c16733b93d | |||
| 23ab3c5198 | |||
| 9b8f121e69 | |||
| 86b1b95fb9 | |||
| 9418681a00 | |||
| 7ebd937e2c | |||
| 25c404f999 | |||
| 6df92bde2a | |||
| 60ff006fec | |||
| 0181f5e10c | |||
| 4bbcc60bbb | |||
| 8e2138e2aa | |||
| 70b9e8c10d | |||
| dc1eddb0df | |||
| 630551cc4b | |||
| 4f94b25182 | |||
| d3b240eae3 | |||
| e2883d6727 | |||
| 21757b37ab | |||
| 9190857ffa | |||
| a607443aa7 | |||
| 41969bc0c2 | |||
| 0d443afb87 | |||
| 3d85294d41 | |||
| 86896771a6 | |||
| 42d52990fa | |||
| 6296c8257f | |||
| 14a40cb1b6 | |||
| d0e3650de5 | |||
| df8ed666ae | |||
| 2217b7f800 | |||
| 924febc2f3 | |||
| 729ffb7952 | |||
| 1568ca7633 | |||
| 7d5e476580 | |||
| 9bcd187358 | |||
| a9cbb6272f | |||
| b524decfa7 | |||
| 5ee01f7735 | |||
| f46f2d6523 | |||
| 435dbf6cee | |||
| 94d38eeb71 | |||
| 0e7f6f90ed | |||
| f3789287dc | |||
| a8312d7c10 | |||
| 612f4a7978 | |||
| 2462347b5f | |||
| 3ec3c0d76b | |||
| 8224aa98f4 | |||
| bb9d9422fb | |||
| f3a184ff00 | |||
| 866e4586df | |||
| a30647893d | |||
| 73826312e5 | |||
| dae34dc0ef | |||
| 17d0c8c26f | |||
| 67831dc162 | |||
| ae84fbe141 | |||
| 8c9bd890e8 | |||
| e8c1956de5 | |||
| 1540a41d3a | |||
| 9cb61cb696 | |||
| 3511325285 | |||
| 62507c5d28 | |||
| 387df29a05 | |||
| 75dca7e1ad | |||
| 2d5e54d5ad | |||
| d62b2b0d6f | |||
| e273719ad1 | |||
| f6726f1c94 |
+132
-83
@@ -1,113 +1,120 @@
|
||||
# TELEGRAM BOT CONFIGURATION
|
||||
# ===============================================
|
||||
# 🤖 REMNAWAVE BEDOLAGA BOT CONFIGURATION
|
||||
# ===============================================
|
||||
|
||||
# ===== TELEGRAM BOT =====
|
||||
BOT_TOKEN=
|
||||
ADMIN_IDS=
|
||||
SUPPORT_USERNAME=
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
# DATABASE CONFIGURATION
|
||||
DATABASE_URL=sqlite+aiosqlite:///./bot.db
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
# ===== DATABASE =====
|
||||
# Для Docker используйте PostgreSQL:
|
||||
DATABASE_URL=postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
# Для локального запуска без Docker используйте SQLite: sqlite+aiosqlite:///./bot.db
|
||||
# DATABASE_URL=postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
|
||||
# REMNAWAVE API CONFIGURATION
|
||||
REMNAWAVE_API_URL=
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Пароли для Docker (PostgreSQL/Redis)
|
||||
POSTGRES_DB=remnawave_bot
|
||||
POSTGRES_USER=remnawave_user
|
||||
POSTGRES_PASSWORD=secure_password_123
|
||||
|
||||
# ===== REMNAWAVE API =====
|
||||
REMNAWAVE_API_URL=https://panel.example.com
|
||||
REMNAWAVE_API_KEY=
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD - https://panel.example.com/auth/login?XXXXXXX=DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=your_secret_key_here
|
||||
|
||||
# === NEW: Traffic Selection Mode Settings ===
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
TRIAL_SQUAD_UUID=
|
||||
|
||||
# ===== ПЛАТНАЯ ПОДПИСКА =====
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
DEFAULT_DEVICE_LIMIT=3
|
||||
|
||||
# Максимум устройств достопных к покупке (0 = Нет лимита)
|
||||
MAX_DEVICES_LIMIT=15
|
||||
|
||||
# Дефолт параметры для подписок выданных через админку
|
||||
DEFAULT_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
|
||||
|
||||
# ===== НАСТРОЙКИ ТРАФИКА =====
|
||||
# Режим выбора трафика:
|
||||
# "selectable" - пользователи выбирают пакеты трафика (по умолчанию)
|
||||
# "fixed" - фиксированный лимит трафика для всех подписок, доступно 5/10/25/50/100/250/0 (0 безлимит) гб
|
||||
# "fixed" - фиксированный лимит трафика для всех подписок
|
||||
TRAFFIC_SELECTION_MODE=selectable
|
||||
|
||||
# Фиксированный лимит трафика в ГБ (используется только в режиме "fixed")
|
||||
# 0 = безлимит
|
||||
# для "fixed" обязательно должы быть проставлены цены на пакеты 5/10/25/50/100/250/0 можно постать 0 руб - будет беслпатно
|
||||
FIXED_TRAFFIC_LIMIT_GB=0
|
||||
FIXED_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# TRIAL SUBSCRIPTION SETTINGS
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=2
|
||||
TRIAL_SQUAD_UUID=
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
|
||||
|
||||
# === УПРАВЛЕНИЕ ПЕРИОДАМИ ПОДПИСКИ ===
|
||||
# ===== ПЕРИОДЫ ПОДПИСКИ =====
|
||||
# Доступные периоды подписки (через запятую)
|
||||
# Возможные значения: 14,30,60,90,180,360
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS=14,30,60,90,180,360
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
|
||||
AVAILABLE_RENEWAL_PERIODS=30,90,180
|
||||
|
||||
# SUBSCRIPTION PRICING (в копейках для точности)
|
||||
BASE_SUBSCRIPTION_PRICE=50000
|
||||
# ===== ЦЕНЫ (в копейках) =====
|
||||
BASE_SUBSCRIPTION_PRICE=0
|
||||
|
||||
PRICE_14_DAYS=5000
|
||||
PRICE_30_DAYS=9900
|
||||
PRICE_60_DAYS=18900
|
||||
PRICE_90_DAYS=26900
|
||||
PRICE_180_DAYS=49900
|
||||
PRICE_360_DAYS=89900
|
||||
# Цены за периоды
|
||||
PRICE_14_DAYS=7000
|
||||
PRICE_30_DAYS=9900
|
||||
PRICE_60_DAYS=25900
|
||||
PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
PRICE_360_DAYS=109900
|
||||
|
||||
PRICE_TRAFFIC_5GB=2000
|
||||
PRICE_TRAFFIC_10GB=4000
|
||||
PRICE_TRAFFIC_25GB=6000
|
||||
PRICE_TRAFFIC_50GB=10000
|
||||
PRICE_TRAFFIC_100GB=15000
|
||||
PRICE_TRAFFIC_250GB=20000
|
||||
PRICE_TRAFFIC_UNLIMITED=25000
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
|
||||
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
|
||||
PRICE_PER_DEVICE=5000
|
||||
|
||||
# REFERRAL SYSTEM SETTINGS
|
||||
REFERRAL_REGISTRATION_REWARD=5000
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_REGISTRATION_REWARD=10000
|
||||
REFERRED_USER_REWARD=10000
|
||||
REFERRAL_COMMISSION_PERCENT=25
|
||||
|
||||
# Режим работы кнопки "Подключиться"
|
||||
# guide - открывает гайд подключения (режим 1)
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
CONNECT_BUTTON_MODE=miniapp_subscription
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
# MINIAPP_CUSTOM_URL=
|
||||
|
||||
# AUTO-PAYMENT SETTINGS
|
||||
# ===== АВТОПРОДЛЕНИЕ =====
|
||||
AUTOPAY_WARNING_DAYS=3,1
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE=3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
|
||||
# MONITORING SETTINGS
|
||||
MONITORING_INTERVAL=60
|
||||
INACTIVE_USER_DELETE_MONTHS=3
|
||||
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
|
||||
|
||||
TRIAL_WARNING_HOURS=2
|
||||
ENABLE_NOTIFICATIONS=true
|
||||
NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
MONITORING_LOGS_RETENTION_DAYS=30
|
||||
|
||||
# PAYMENT SYSTEMS
|
||||
# Telegram Stars (работает автоматически)
|
||||
TELEGRAM_STARS_ENABLED=true
|
||||
TELEGRAM_STARS_RATE_RUB=1.3
|
||||
|
||||
# Tribute (https://tribute.app)
|
||||
TRIBUTE_ENABLED=false
|
||||
TRIBUTE_API_KEY=
|
||||
TRIBUTE_WEBHOOK_SECRET=your_webhook_secret
|
||||
TRIBUTE_DONATE_LINK=https://t.me/tribute/app?startapp=XXXX
|
||||
TRIBUTE_DONATE_LINK=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
|
||||
# === НОВЫЕ НАСТРОЙКИ YOOKASSA ===
|
||||
# Включение/выключение YooKassa
|
||||
# YooKassa (https://yookassa.ru)
|
||||
YOOKASSA_ENABLED=false
|
||||
|
||||
# Основные настройки YooKassa (получить в личном кабинете)
|
||||
YOOKASSA_SHOP_ID=your_shop_id_here
|
||||
YOOKASSA_SECRET_KEY=your_secret_key_here
|
||||
|
||||
# URL для возврата после оплаты (необязательно, по умолчанию t.me/your_bot)
|
||||
YOOKASSA_RETURN_URL=https://yourdomain.com/payment-success
|
||||
|
||||
# Email по умолчанию для чеков (если пользователь не указал свой)
|
||||
YOOKASSA_SHOP_ID=
|
||||
YOOKASSA_SECRET_KEY=
|
||||
YOOKASSA_RETURN_URL=
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
|
||||
|
||||
# Настройки чеков для налоговой
|
||||
YOOKASSA_VAT_CODE=1
|
||||
# Коды НДС:
|
||||
# 1 - НДС не облагается
|
||||
# 2 - НДС 0%
|
||||
# 2 - НДС 0%
|
||||
# 3 - НДС 10%
|
||||
# 4 - НДС 20%
|
||||
# 5 - НДС 10/110
|
||||
@@ -116,7 +123,7 @@ YOOKASSA_VAT_CODE=1
|
||||
YOOKASSA_PAYMENT_MODE=full_payment
|
||||
# Способы расчета:
|
||||
# full_payment - полная оплата
|
||||
# partial_payment - частичная оплата
|
||||
# partial_payment - частичная оплата
|
||||
# advance - аванс
|
||||
# full_prepayment - полная предоплата
|
||||
# partial_prepayment - частичная предоплата
|
||||
@@ -126,7 +133,7 @@ YOOKASSA_PAYMENT_MODE=full_payment
|
||||
YOOKASSA_PAYMENT_SUBJECT=service
|
||||
# Предметы расчета:
|
||||
# commodity - товар
|
||||
# excise - подакцизный товар
|
||||
# excise - подакцизный товар
|
||||
# job - работа
|
||||
# service - услуга
|
||||
# gambling_bet - ставка в азартной игре
|
||||
@@ -139,21 +146,63 @@ YOOKASSA_PAYMENT_SUBJECT=service
|
||||
# composite - составной предмет расчета
|
||||
# another - другое
|
||||
|
||||
# Webhook для получения уведомлений от YooKassa
|
||||
# Webhook настройки
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=ваш_секретный_ключ_для_webhook
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
WEBHOOK_URL=https://example.com
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
PAYMENT_SERVICE_NAME=Интернет-сервис
|
||||
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
|
||||
PAYMENT_SUBSCRIPTION_DESCRIPTION=Оплата подписки
|
||||
PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
|
||||
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Режим работы кнопки "Подключиться"
|
||||
# guide - открывает гайд подключения (режим 1)
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
|
||||
# ===== МОНИТОРИНГ И УВЕДОМЛЕНИЯ =====
|
||||
MONITORING_INTERVAL=60
|
||||
INACTIVE_USER_DELETE_MONTHS=3
|
||||
|
||||
# Уведомления
|
||||
TRIAL_WARNING_HOURS=2
|
||||
ENABLE_NOTIFICATIONS=true
|
||||
NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
MONITORING_LOGS_RETENTION_DAYS=30
|
||||
NOTIFICATION_CACHE_HOURS=24
|
||||
|
||||
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
|
||||
# ===== ЛОГИРОВАНИЕ =====
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/bot.log
|
||||
|
||||
# ===== РАЗРАБОТКА =====
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
|
||||
# LOCALIZATION
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru
|
||||
|
||||
# LOGGING
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=/tmp/bot.log
|
||||
|
||||
# DEVELOPMENT
|
||||
DEBUG=false
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
@@ -4,12 +4,14 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
@@ -42,6 +44,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
|
||||
+18
-10
@@ -1,35 +1,43 @@
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Create non-root user with specific UID/GID for consistency
|
||||
RUN groupadd -g 1000 app && \
|
||||
useradd -u 1000 -g 1000 -m -s /bin/bash app
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create logs directory
|
||||
RUN mkdir -p logs
|
||||
# Create necessary directories and set permissions
|
||||
RUN mkdir -p logs data && \
|
||||
chown -R app:app /app && \
|
||||
chmod -R 755 /app && \
|
||||
chmod -R 777 logs
|
||||
|
||||
# Switch to non-root user
|
||||
USER app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd --create-home --shell /bin/bash app
|
||||
RUN chown -R app:app /app
|
||||
USER app
|
||||
|
||||
# Expose webhook ports для платежных систем
|
||||
# Expose webhook ports
|
||||
EXPOSE 8081
|
||||
EXPOSE 8082
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
+8
-1
@@ -25,6 +25,7 @@ from app.handlers.admin import (
|
||||
statistics as admin_statistics, servers as admin_servers,
|
||||
maintenance as admin_maintenance
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,6 +53,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
)
|
||||
|
||||
maintenance_service.set_bot(bot)
|
||||
logger.info("Бот установлен в maintenance_service")
|
||||
|
||||
try:
|
||||
redis_client = redis.from_url(settings.REDIS_URL)
|
||||
await redis_client.ping()
|
||||
@@ -67,6 +71,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(AuthMiddleware())
|
||||
dp.callback_query.middleware(AuthMiddleware())
|
||||
dp.pre_checkout_query.middleware(AuthMiddleware())
|
||||
dp.message.middleware(MaintenanceMiddleware())
|
||||
dp.callback_query.middleware(MaintenanceMiddleware())
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
@@ -95,6 +100,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
|
||||
common.register_handlers(dp)
|
||||
|
||||
register_stars_handlers(dp)
|
||||
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
@@ -107,7 +115,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
|
||||
|
||||
async def shutdown_bot():
|
||||
"""Корректное завершение работы бота"""
|
||||
try:
|
||||
await maintenance_service.stop_monitoring()
|
||||
logger.info("Мониторинг техработ остановлен")
|
||||
|
||||
+181
-17
@@ -1,5 +1,5 @@
|
||||
import os
|
||||
from typing import List, Optional, Union
|
||||
from typing import List, Optional, Union, Dict
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
from pathlib import Path
|
||||
@@ -16,6 +16,7 @@ class Settings(BaseSettings):
|
||||
|
||||
REMNAWAVE_API_URL: str
|
||||
REMNAWAVE_API_KEY: str
|
||||
REMNAWAVE_SECRET_KEY: Optional[str] = None
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
@@ -24,6 +25,7 @@ class Settings(BaseSettings):
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
TRIAL_SQUAD_UUID: str
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
|
||||
TRIAL_WARNING_HOURS: int = 2
|
||||
ENABLE_NOTIFICATIONS: bool = True
|
||||
@@ -43,13 +45,17 @@ class Settings(BaseSettings):
|
||||
PRICE_180_DAYS: int = 499000
|
||||
PRICE_360_DAYS: int = 899000
|
||||
|
||||
PRICE_TRAFFIC_5GB: int = 10000
|
||||
PRICE_TRAFFIC_10GB: int = 19000
|
||||
PRICE_TRAFFIC_25GB: int = 45000
|
||||
PRICE_TRAFFIC_50GB: int = 85000
|
||||
PRICE_TRAFFIC_100GB: int = 159000
|
||||
PRICE_TRAFFIC_250GB: int = 369000
|
||||
PRICE_TRAFFIC_UNLIMITED: int = 0
|
||||
PRICE_TRAFFIC_5GB: int = 2000
|
||||
PRICE_TRAFFIC_10GB: int = 3500
|
||||
PRICE_TRAFFIC_25GB: int = 7000
|
||||
PRICE_TRAFFIC_50GB: int = 11000
|
||||
PRICE_TRAFFIC_100GB: int = 15000
|
||||
PRICE_TRAFFIC_250GB: int = 17000
|
||||
PRICE_TRAFFIC_500GB: int = 19000
|
||||
PRICE_TRAFFIC_1000GB: int = 19500
|
||||
PRICE_TRAFFIC_UNLIMITED: int = 20000
|
||||
|
||||
TRAFFIC_PACKAGES_CONFIG: str = ""
|
||||
|
||||
PRICE_PER_DEVICE: int = 5000
|
||||
|
||||
@@ -74,6 +80,7 @@ class Settings(BaseSettings):
|
||||
MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже."
|
||||
|
||||
TELEGRAM_STARS_ENABLED: bool = True
|
||||
TELEGRAM_STARS_RATE_RUB: float = 1.3
|
||||
|
||||
TRIBUTE_ENABLED: bool = False
|
||||
TRIBUTE_API_KEY: Optional[str] = None
|
||||
@@ -93,6 +100,12 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
|
||||
YOOKASSA_WEBHOOK_PORT: int = 8082
|
||||
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
|
||||
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
|
||||
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
|
||||
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
|
||||
PAYMENT_BALANCE_TEMPLATE: str = "{service_name} - {description}"
|
||||
PAYMENT_SUBSCRIPTION_TEMPLATE: str = "{service_name} - {description}"
|
||||
|
||||
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
@@ -256,6 +269,155 @@ class Settings(BaseSettings):
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
return [30, 90, 180]
|
||||
|
||||
def get_balance_payment_description(self, amount_kopeks: int) -> str:
|
||||
return self.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=self.PAYMENT_SERVICE_NAME,
|
||||
description=f"{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}"
|
||||
)
|
||||
|
||||
def get_subscription_payment_description(self, period_days: int, amount_kopeks: int) -> str:
|
||||
return self.PAYMENT_SUBSCRIPTION_TEMPLATE.format(
|
||||
service_name=self.PAYMENT_SERVICE_NAME,
|
||||
description=f"{self.PAYMENT_SUBSCRIPTION_DESCRIPTION} на {period_days} дней"
|
||||
)
|
||||
|
||||
def get_custom_payment_description(self, description: str) -> str:
|
||||
return self.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=self.PAYMENT_SERVICE_NAME,
|
||||
description=description
|
||||
)
|
||||
|
||||
def get_stars_rate(self) -> float:
|
||||
return self.TELEGRAM_STARS_RATE_RUB
|
||||
|
||||
def stars_to_rubles(self, stars: int) -> float:
|
||||
return stars * self.get_stars_rate()
|
||||
|
||||
def rubles_to_stars(self, rubles: float) -> int:
|
||||
return max(1, int(rubles / self.get_stars_rate()))
|
||||
|
||||
def get_traffic_packages(self) -> List[Dict]:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
packages = []
|
||||
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
|
||||
|
||||
logger.info(f"CONFIG STRING: '{config_str}'")
|
||||
|
||||
if not config_str:
|
||||
logger.info("CONFIG EMPTY, USING FALLBACK")
|
||||
return self._get_fallback_traffic_packages()
|
||||
|
||||
logger.info("PARSING CONFIG...")
|
||||
|
||||
for package_config in config_str.split(','):
|
||||
package_config = package_config.strip()
|
||||
if not package_config:
|
||||
continue
|
||||
|
||||
parts = package_config.split(':')
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
|
||||
try:
|
||||
gb = int(parts[0])
|
||||
price = int(parts[1])
|
||||
enabled = parts[2].lower() == 'true'
|
||||
|
||||
packages.append({
|
||||
"gb": gb,
|
||||
"price": price,
|
||||
"enabled": enabled
|
||||
})
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
logger.info(f"PARSED {len(packages)} packages from config")
|
||||
return packages if packages else self._get_fallback_traffic_packages()
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"ERROR PARSING CONFIG: {e}")
|
||||
return self._get_fallback_traffic_packages()
|
||||
|
||||
def _get_fallback_traffic_packages(self) -> List[Dict]:
|
||||
try:
|
||||
if self.TRAFFIC_PACKAGES_CONFIG.strip():
|
||||
packages = []
|
||||
for package_config in self.TRAFFIC_PACKAGES_CONFIG.split(','):
|
||||
package_config = package_config.strip()
|
||||
if not package_config:
|
||||
continue
|
||||
|
||||
parts = package_config.split(':')
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
|
||||
try:
|
||||
gb = int(parts[0])
|
||||
price = int(parts[1])
|
||||
enabled = parts[2].lower() == 'true'
|
||||
|
||||
packages.append({
|
||||
"gb": gb,
|
||||
"price": price,
|
||||
"enabled": enabled
|
||||
})
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if packages:
|
||||
return packages
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return [
|
||||
{"gb": 5, "price": self.PRICE_TRAFFIC_5GB, "enabled": True},
|
||||
{"gb": 10, "price": self.PRICE_TRAFFIC_10GB, "enabled": True},
|
||||
{"gb": 25, "price": self.PRICE_TRAFFIC_25GB, "enabled": True},
|
||||
{"gb": 50, "price": self.PRICE_TRAFFIC_50GB, "enabled": True},
|
||||
{"gb": 100, "price": self.PRICE_TRAFFIC_100GB, "enabled": True},
|
||||
{"gb": 250, "price": self.PRICE_TRAFFIC_250GB, "enabled": True},
|
||||
{"gb": 500, "price": self.PRICE_TRAFFIC_500GB, "enabled": True},
|
||||
{"gb": 1000, "price": self.PRICE_TRAFFIC_1000GB, "enabled": True},
|
||||
{"gb": 0, "price": self.PRICE_TRAFFIC_UNLIMITED, "enabled": True},
|
||||
]
|
||||
|
||||
def get_traffic_price(self, gb: int) -> int:
|
||||
packages = self.get_traffic_packages()
|
||||
|
||||
for package in packages:
|
||||
if package["gb"] == gb and package["enabled"]:
|
||||
return package["price"]
|
||||
|
||||
|
||||
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
|
||||
if not enabled_packages:
|
||||
return 0
|
||||
|
||||
unlimited_package = next((pkg for pkg in enabled_packages if pkg["gb"] == 0), None)
|
||||
|
||||
finite_packages = [pkg for pkg in enabled_packages if pkg["gb"] > 0]
|
||||
if finite_packages:
|
||||
max_package = max(finite_packages, key=lambda x: x["gb"])
|
||||
|
||||
if gb > max_package["gb"]:
|
||||
if unlimited_package:
|
||||
return unlimited_package["price"]
|
||||
else:
|
||||
return max_package["price"]
|
||||
|
||||
suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb]
|
||||
if suitable_packages:
|
||||
nearest_package = min(suitable_packages, key=lambda x: x["gb"])
|
||||
return nearest_package["price"]
|
||||
|
||||
if unlimited_package:
|
||||
return unlimited_package["price"]
|
||||
|
||||
return 0
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
@@ -275,12 +437,14 @@ PERIOD_PRICES = {
|
||||
360: settings.PRICE_360_DAYS,
|
||||
}
|
||||
|
||||
TRAFFIC_PRICES = {
|
||||
5: settings.PRICE_TRAFFIC_5GB,
|
||||
10: settings.PRICE_TRAFFIC_10GB,
|
||||
25: settings.PRICE_TRAFFIC_25GB,
|
||||
50: settings.PRICE_TRAFFIC_50GB,
|
||||
100: settings.PRICE_TRAFFIC_100GB,
|
||||
250: settings.PRICE_TRAFFIC_250GB,
|
||||
0: settings.PRICE_TRAFFIC_UNLIMITED,
|
||||
}
|
||||
def get_traffic_prices() -> Dict[int, int]:
|
||||
packages = settings.get_traffic_packages()
|
||||
return {package["gb"]: package["price"] for package in packages}
|
||||
|
||||
TRAFFIC_PRICES = get_traffic_prices()
|
||||
|
||||
def refresh_traffic_prices():
|
||||
global TRAFFIC_PRICES
|
||||
TRAFFIC_PRICES = get_traffic_prices()
|
||||
|
||||
refresh_traffic_prices()
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoCode, PromoCodeUse, PromoCodeType
|
||||
from app.database.models import PromoCode, PromoCodeUse, PromoCodeType, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -187,8 +187,13 @@ async def update_promocode(
|
||||
|
||||
|
||||
async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
|
||||
|
||||
try:
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
await db.execute(
|
||||
sql_delete(PromoCodeUse).where(PromoCodeUse.promocode_id == promocode.id)
|
||||
)
|
||||
|
||||
await db.delete(promocode)
|
||||
await db.commit()
|
||||
|
||||
@@ -222,15 +227,24 @@ async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
|
||||
today_uses = today_uses_result.scalar()
|
||||
|
||||
recent_uses_result = await db.execute(
|
||||
select(PromoCodeUse)
|
||||
select(PromoCodeUse, User)
|
||||
.join(User, PromoCodeUse.user_id == User.id)
|
||||
.where(PromoCodeUse.promocode_id == promocode_id)
|
||||
.order_by(PromoCodeUse.used_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
recent_uses = recent_uses_result.scalars().all()
|
||||
recent_uses_data = recent_uses_result.all()
|
||||
|
||||
recent_uses = []
|
||||
for use, user in recent_uses_data:
|
||||
use.user_username = user.username
|
||||
use.user_full_name = user.full_name
|
||||
use.user_telegram_id = user.telegram_id
|
||||
recent_uses.append(use)
|
||||
|
||||
return {
|
||||
"total_uses": total_uses,
|
||||
"today_uses": today_uses,
|
||||
"recent_uses": recent_uses
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -512,7 +512,7 @@ async def get_subscription_renewal_cost(
|
||||
if subscription.traffic_limit_gb > 0:
|
||||
traffic_cost = TRAFFIC_PRICES.get(subscription.traffic_limit_gb, 0)
|
||||
|
||||
devices_cost = max(0, subscription.device_limit - 1) * settings.PRICE_PER_DEVICE
|
||||
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_cost = base_price + servers_cost + traffic_cost + devices_cost
|
||||
|
||||
|
||||
Vendored
+34
-11
@@ -8,6 +8,7 @@ import aiohttp
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,7 +34,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
|
||||
@@ -92,10 +93,12 @@ class RemnaWaveAPIError(Exception):
|
||||
|
||||
class RemnaWaveAPI:
|
||||
|
||||
def __init__(self, base_url: str, api_key: str):
|
||||
def __init__(self, base_url: str, api_key: str, secret_key: Optional[str] = None):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_key = api_key
|
||||
self.secret_key = secret_key
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
self.authenticated = False
|
||||
|
||||
def _detect_connection_type(self) -> str:
|
||||
parsed = urlparse(self.base_url)
|
||||
@@ -124,18 +127,29 @@ class RemnaWaveAPI:
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
'X-Forwarded-For': '127.0.0.1',
|
||||
'X-Real-IP': '127.0.0.1'
|
||||
}
|
||||
|
||||
cookies = None
|
||||
if self.secret_key:
|
||||
if ':' in self.secret_key:
|
||||
key_name, key_value = self.secret_key.split(':', 1)
|
||||
cookies = {key_name: key_value}
|
||||
logger.debug(f"🍪 Используем куки: {key_name}=***")
|
||||
else:
|
||||
cookies = {self.secret_key: self.secret_key}
|
||||
logger.debug(f"🍪 Используем куки: {self.secret_key}=***")
|
||||
|
||||
connector_kwargs = {}
|
||||
|
||||
if conn_type == "local":
|
||||
logger.debug("🏠 Использую локальные заголовки proxy")
|
||||
headers.update({
|
||||
'X-Forwarded-For': '127.0.0.1',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
'X-Forwarded-Host': 'localhost',
|
||||
'X-Real-IP': '127.0.0.1',
|
||||
'Host': 'localhost'
|
||||
})
|
||||
|
||||
@@ -152,11 +166,18 @@ class RemnaWaveAPI:
|
||||
|
||||
connector = aiohttp.TCPConnector(**connector_kwargs)
|
||||
|
||||
self.session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
headers=headers,
|
||||
connector=connector
|
||||
)
|
||||
session_kwargs = {
|
||||
'timeout': aiohttp.ClientTimeout(total=30),
|
||||
'headers': headers,
|
||||
'connector': connector
|
||||
}
|
||||
|
||||
if cookies:
|
||||
session_kwargs['cookies'] = cookies
|
||||
|
||||
self.session = aiohttp.ClientSession(**session_kwargs)
|
||||
self.authenticated = True
|
||||
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
@@ -194,6 +215,8 @@ class RemnaWaveAPI:
|
||||
|
||||
if response.status >= 400:
|
||||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||||
logger.error(f"API Error {response.status}: {error_message}")
|
||||
logger.error(f"Response: {response_text[:500]}")
|
||||
raise RemnaWaveAPIError(
|
||||
error_message,
|
||||
response.status,
|
||||
|
||||
Vendored
+22
-10
@@ -13,6 +13,14 @@ class TelegramStarsService:
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
|
||||
@staticmethod
|
||||
def calculate_stars_from_rubles(rubles: float) -> int:
|
||||
return settings.rubles_to_stars(rubles)
|
||||
|
||||
@staticmethod
|
||||
def calculate_rubles_from_stars(stars: int) -> float:
|
||||
return settings.stars_to_rubles(stars)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
chat_id: int,
|
||||
@@ -23,7 +31,8 @@ class TelegramStarsService:
|
||||
start_parameter: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
stars_amount = max(1, amount_kopeks // 100)
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title=title,
|
||||
@@ -35,7 +44,10 @@ class TelegramStarsService:
|
||||
start_parameter=start_parameter
|
||||
)
|
||||
|
||||
logger.info(f"Создан Stars invoice на {stars_amount} звезд для {chat_id}")
|
||||
logger.info(
|
||||
f"Создан Stars invoice на {stars_amount} звезд (~{amount_rubles:.2f}₽) "
|
||||
f"для {chat_id}, курс: {settings.get_stars_rate():.2f}₽/⭐"
|
||||
)
|
||||
return invoice_link
|
||||
|
||||
except Exception as e:
|
||||
@@ -52,7 +64,8 @@ class TelegramStarsService:
|
||||
keyboard: Optional[InlineKeyboardMarkup] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
stars_amount = max(1, amount_kopeks // 100)
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
message = await self.bot.send_invoice(
|
||||
chat_id=chat_id,
|
||||
@@ -65,10 +78,14 @@ class TelegramStarsService:
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
logger.info(f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд")
|
||||
logger.info(
|
||||
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
|
||||
f"(~{amount_rubles:.2f}₽), курс: {settings.get_stars_rate():.2f}₽/⭐"
|
||||
)
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
"stars_amount": stars_amount,
|
||||
"rubles_amount": amount_rubles,
|
||||
"payload": payload
|
||||
}
|
||||
|
||||
@@ -88,13 +105,8 @@ class TelegramStarsService:
|
||||
ok=ok,
|
||||
error_message=error_message
|
||||
)
|
||||
logger.info(f"Ответ на pre_checkout_query: ok={ok}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка ответа на pre_checkout_query: {e}")
|
||||
return False
|
||||
|
||||
def calculate_stars_amount(self, rubles: float) -> int:
|
||||
return max(1, int(rubles))
|
||||
|
||||
def calculate_rubles_from_stars(self, stars: int) -> float:
|
||||
return float(stars)
|
||||
@@ -45,7 +45,8 @@ async def show_messages_menu(
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_admin_messages_keyboard(db_user.language)
|
||||
reply_markup=get_admin_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -60,7 +61,8 @@ async def show_broadcast_targets(
|
||||
await callback.message.edit_text(
|
||||
"🎯 <b>Выбор целевой аудитории</b>\n\n"
|
||||
"Выберите категорию пользователей для рассылки:",
|
||||
reply_markup=get_broadcast_target_keyboard(db_user.language)
|
||||
reply_markup=get_broadcast_target_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -118,7 +120,8 @@ async def show_messages_history(
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -135,7 +138,7 @@ async def show_custom_broadcast(
|
||||
stats = await get_users_statistics(db)
|
||||
|
||||
text = f"""
|
||||
🔍 <b>Рассылка по критериям</b>
|
||||
📝 <b>Рассылка по критериям</b>
|
||||
|
||||
📊 <b>Доступные фильтры:</b>
|
||||
|
||||
@@ -158,7 +161,8 @@ async def show_custom_broadcast(
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_custom_criteria_keyboard(db_user.language)
|
||||
reply_markup=get_custom_criteria_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -196,7 +200,8 @@ async def select_custom_criteria(
|
||||
f"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
])
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
@@ -233,7 +238,8 @@ async def select_broadcast_target(
|
||||
f"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
])
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
@@ -284,7 +290,8 @@ async def process_broadcast_message(
|
||||
|
||||
await message.answer(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_broadcast)
|
||||
|
||||
@@ -304,7 +311,8 @@ async def confirm_broadcast(
|
||||
await callback.message.edit_text(
|
||||
"📨 Начинаю рассылку...\n\n"
|
||||
"⏳ Это может занять несколько минут.",
|
||||
reply_markup=None
|
||||
reply_markup=None,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
if target.startswith('custom_'):
|
||||
@@ -367,7 +375,8 @@ async def confirm_broadcast(
|
||||
result_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")]
|
||||
])
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
@@ -548,4 +557,4 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_custom_broadcast, F.data == "admin_msg_custom")
|
||||
dp.callback_query.register(select_custom_criteria, F.data.startswith("criteria_"))
|
||||
|
||||
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
|
||||
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
|
||||
|
||||
+547
-110
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, PromoCodeType
|
||||
from app.database.models import PromoCode, PromoCodeUse, PromoCodeType, User
|
||||
from app.keyboards.admin import (
|
||||
get_admin_promocodes_keyboard, get_promocode_type_keyboard,
|
||||
get_admin_pagination_keyboard, get_confirmation_keyboard
|
||||
@@ -77,6 +77,7 @@ async def show_promocodes_list(
|
||||
return
|
||||
|
||||
text = f"🎫 <b>Список промокодов</b> (стр. {page}/{total_pages})\n\n"
|
||||
keyboard = []
|
||||
|
||||
for promo in promocodes:
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
@@ -93,9 +94,14 @@ async def show_promocodes_list(
|
||||
if promo.valid_until:
|
||||
text += f"⏰ До: {format_datetime(promo.valid_until)}\n"
|
||||
|
||||
text += f"🔧 Управление: /promo_{promo.id}\n\n"
|
||||
|
||||
keyboard = []
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"🎫 {promo.code}",
|
||||
callback_data=f"promo_manage_{promo.id}"
|
||||
)
|
||||
])
|
||||
|
||||
text += "\n"
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = get_admin_pagination_keyboard(
|
||||
@@ -182,128 +188,75 @@ async def show_promocode_management(
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_promocode_status(
|
||||
async def show_promocode_edit_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
try:
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
new_status = not promo.is_active
|
||||
await update_promocode(db, promo, is_active=new_status)
|
||||
|
||||
status_text = "активирован" if new_status else "деактивирован"
|
||||
await callback.answer(f"✅ Промокод {status_text}", show_alert=True)
|
||||
|
||||
await show_promocode_management(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_delete_promocode(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
text = f"""
|
||||
⚠️ <b>Подтверждение удаления</b>
|
||||
✏️ <b>Редактирование промокода</b> <code>{promo.code}</code>
|
||||
|
||||
Вы действительно хотите удалить промокод <code>{promo.code}</code>?
|
||||
|
||||
<b>Внимание:</b> Это действие нельзя отменить!
|
||||
💰 <b>Текущие параметры:</b>
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
text += f"• Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n"
|
||||
elif promo.type in [PromoCodeType.SUBSCRIPTION_DAYS.value, PromoCodeType.TRIAL_SUBSCRIPTION.value]:
|
||||
text += f"• Дней: {promo.subscription_days}\n"
|
||||
|
||||
text += f"• Использований: {promo.current_uses}/{promo.max_uses}\n"
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"• До: {format_datetime(promo.valid_until)}\n"
|
||||
else:
|
||||
text += f"• Срок: бессрочно\n"
|
||||
|
||||
text += f"\nВыберите параметр для изменения:"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Да, удалить",
|
||||
callback_data=f"promo_delete_confirm_{promo.id}"
|
||||
),
|
||||
text="📅 Дата окончания",
|
||||
callback_data=f"promo_edit_date_{promo.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data=f"promo_manage_{promo.id}"
|
||||
text="📊 Количество использований",
|
||||
callback_data=f"promo_edit_uses_{promo.id}"
|
||||
)
|
||||
]
|
||||
])
|
||||
]
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_promocode_confirmed(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
keyboard.insert(1, [
|
||||
types.InlineKeyboardButton(
|
||||
text="💰 Сумма бонуса",
|
||||
callback_data=f"promo_edit_amount_{promo.id}"
|
||||
)
|
||||
])
|
||||
elif promo.type in [PromoCodeType.SUBSCRIPTION_DAYS.value, PromoCodeType.TRIAL_SUBSCRIPTION.value]:
|
||||
keyboard.insert(1, [
|
||||
types.InlineKeyboardButton(
|
||||
text="📅 Количество дней",
|
||||
callback_data=f"promo_edit_days_{promo.id}"
|
||||
)
|
||||
])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
code = promo.code
|
||||
success = await delete_promocode(db, promo)
|
||||
|
||||
if success:
|
||||
await callback.answer(f"✅ Промокод {code} удален", show_alert=True)
|
||||
await show_promocodes_list(callback, db_user, db)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка удаления промокода", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promocode_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
stats = await get_promocode_statistics(db, promo_id)
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика промокода</b> <code>{promo.code}</code>
|
||||
|
||||
📈 <b>Общая статистика:</b>
|
||||
- Всего использований: {stats['total_uses']}
|
||||
- Использований сегодня: {stats['today_uses']}
|
||||
- Осталось использований: {promo.max_uses - promo.current_uses}
|
||||
|
||||
📅 <b>Последние использования:</b>
|
||||
"""
|
||||
|
||||
for use in stats['recent_uses'][:5]:
|
||||
use_date = format_datetime(use.used_at)
|
||||
text += f"- {use_date} (ID: {use.user_id})\n"
|
||||
|
||||
if not stats['recent_uses']:
|
||||
text += "- Пока не было использований\n"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
keyboard.extend([
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
@@ -312,7 +265,160 @@ async def show_promocode_stats(
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_promocode_date(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
try:
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
editing_promo_id=promo_id,
|
||||
edit_action="date"
|
||||
)
|
||||
|
||||
text = f"""
|
||||
📅 <b>Изменение даты окончания промокода</b>
|
||||
|
||||
Введите количество дней до окончания (от текущего момента):
|
||||
• Введите <b>0</b> для бессрочного промокода
|
||||
• Введите положительное число для установки срока
|
||||
|
||||
<i>Например: 30 (промокод будет действовать 30 дней)</i>
|
||||
|
||||
ID промокода: {promo_id}
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.setting_promocode_expiry)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_promocode_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
try:
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
editing_promo_id=promo_id,
|
||||
edit_action="amount"
|
||||
)
|
||||
|
||||
text = f"""
|
||||
💰 <b>Изменение суммы бонуса промокода</b>
|
||||
|
||||
Введите новую сумму в рублях:
|
||||
<i>Например: 500</i>
|
||||
|
||||
ID промокода: {promo_id}
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.setting_promocode_value)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_promocode_days(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
# ИСПРАВЛЕНИЕ: берем последний элемент как ID
|
||||
try:
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
editing_promo_id=promo_id,
|
||||
edit_action="days"
|
||||
)
|
||||
|
||||
text = f"""
|
||||
📅 <b>Изменение количества дней подписки</b>
|
||||
|
||||
Введите новое количество дней:
|
||||
<i>Например: 30</i>
|
||||
|
||||
ID промокода: {promo_id}
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.setting_promocode_value)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_promocode_uses(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
try:
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
editing_promo_id=promo_id,
|
||||
edit_action="uses"
|
||||
)
|
||||
|
||||
text = f"""
|
||||
📊 <b>Изменение максимального количества использований</b>
|
||||
|
||||
Введите новое количество использований:
|
||||
• Введите <b>0</b> для безлимитных использований
|
||||
• Введите положительное число для ограничения
|
||||
|
||||
<i>Например: 100</i>
|
||||
|
||||
ID промокода: {promo_id}
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.setting_promocode_uses)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -375,7 +481,6 @@ async def process_promocode_code(
|
||||
await message.answer("❌ Код должен содержать только латинские буквы и цифры (3-20 символов)")
|
||||
return
|
||||
|
||||
from app.database.crud.promocode import get_promocode_by_code
|
||||
existing = await get_promocode_by_code(db, code)
|
||||
if existing:
|
||||
await message.answer("❌ Промокод с таким кодом уже существует")
|
||||
@@ -411,12 +516,18 @@ async def process_promocode_code(
|
||||
async def process_promocode_value(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
|
||||
if data.get('editing_promo_id'):
|
||||
await handle_edit_value(message, db_user, state, db)
|
||||
return
|
||||
|
||||
try:
|
||||
value = int(message.text.strip())
|
||||
|
||||
data = await state.get_data()
|
||||
promo_type = data.get('promocode_type')
|
||||
|
||||
if promo_type == "balance" and (value < 1 or value > 10000):
|
||||
@@ -437,13 +548,72 @@ async def process_promocode_value(
|
||||
await message.answer("❌ Введите корректное число")
|
||||
|
||||
|
||||
async def handle_edit_value(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
edit_action = data.get('edit_action')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
value = int(message.text.strip())
|
||||
|
||||
if edit_action == "amount":
|
||||
if value < 1 or value > 10000:
|
||||
await message.answer("❌ Сумма должна быть от 1 до 10,000 рублей")
|
||||
return
|
||||
|
||||
await update_promocode(db, promo, balance_bonus_kopeks=value * 100)
|
||||
await message.answer(
|
||||
f"✅ Сумма бонуса изменена на {value}₽",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
elif edit_action == "days":
|
||||
if value < 1 or value > 3650:
|
||||
await message.answer("❌ Количество дней должно быть от 1 до 3650")
|
||||
return
|
||||
|
||||
await update_promocode(db, promo, subscription_days=value)
|
||||
await message.answer(
|
||||
f"✅ Количество дней изменено на {value}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: {edit_action} = {value}")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_promocode_uses(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
|
||||
if data.get('editing_promo_id'):
|
||||
await handle_edit_uses(message, db_user, state, db)
|
||||
return
|
||||
|
||||
try:
|
||||
max_uses = int(message.text.strip())
|
||||
|
||||
@@ -465,6 +635,54 @@ async def process_promocode_uses(
|
||||
await message.answer("❌ Введите корректное число")
|
||||
|
||||
|
||||
async def handle_edit_uses(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
max_uses = int(message.text.strip())
|
||||
|
||||
if max_uses < 0 or max_uses > 100000:
|
||||
await message.answer("❌ Количество использований должно быть от 0 до 100,000")
|
||||
return
|
||||
|
||||
if max_uses == 0:
|
||||
max_uses = 999999
|
||||
|
||||
if max_uses < promo.current_uses:
|
||||
await message.answer(
|
||||
f"❌ Новый лимит ({max_uses}) не может быть меньше текущих использований ({promo.current_uses})"
|
||||
)
|
||||
return
|
||||
|
||||
await update_promocode(db, promo, max_uses=max_uses)
|
||||
|
||||
uses_text = "безлимитное" if max_uses == 999999 else str(max_uses)
|
||||
await message.answer(
|
||||
f"✅ Максимальное количество использований изменено на {uses_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: max_uses = {max_uses}")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_promocode_expiry(
|
||||
@@ -473,6 +691,12 @@ async def process_promocode_expiry(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
|
||||
if data.get('editing_promo_id'):
|
||||
await handle_edit_expiry(message, db_user, state, db)
|
||||
return
|
||||
|
||||
try:
|
||||
expiry_days = int(message.text.strip())
|
||||
|
||||
@@ -480,7 +704,6 @@ async def process_promocode_expiry(
|
||||
await message.answer("❌ Срок действия должен быть от 0 до 3650 дней")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
code = data.get('promocode_code')
|
||||
promo_type = data.get('promocode_type')
|
||||
value = data.get('promocode_value', 0)
|
||||
@@ -544,6 +767,209 @@ async def process_promocode_expiry(
|
||||
await message.answer("❌ Введите корректное число дней")
|
||||
|
||||
|
||||
async def handle_edit_expiry(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
expiry_days = int(message.text.strip())
|
||||
|
||||
if expiry_days < 0 or expiry_days > 3650:
|
||||
await message.answer("❌ Срок действия должен быть от 0 до 3650 дней")
|
||||
return
|
||||
|
||||
valid_until = None
|
||||
if expiry_days > 0:
|
||||
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
|
||||
|
||||
await update_promocode(db, promo, valid_until=valid_until)
|
||||
|
||||
if valid_until:
|
||||
expiry_text = f"до {format_datetime(valid_until)}"
|
||||
else:
|
||||
expiry_text = "бессрочно"
|
||||
|
||||
await message.answer(
|
||||
f"✅ Срок действия промокода изменен: {expiry_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: expiry = {expiry_days} дней")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число дней")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_promocode_status(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
new_status = not promo.is_active
|
||||
await update_promocode(db, promo, is_active=new_status)
|
||||
|
||||
status_text = "активирован" if new_status else "деактивирован"
|
||||
await callback.answer(f"✅ Промокод {status_text}", show_alert=True)
|
||||
|
||||
await show_promocode_management(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_delete_promocode(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
text = f"""
|
||||
⚠️ <b>Подтверждение удаления</b>
|
||||
|
||||
Вы действительно хотите удалить промокод <code>{promo.code}</code>?
|
||||
|
||||
📊 <b>Информация о промокоде:</b>
|
||||
• Использований: {promo.current_uses}/{promo.max_uses}
|
||||
• Статус: {'Активен' if promo.is_active else 'Неактивен'}
|
||||
|
||||
<b>⚠️ Внимание:</b> Это действие нельзя отменить!
|
||||
|
||||
ID: {promo_id}
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Да, удалить",
|
||||
callback_data=f"promo_delete_confirm_{promo.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data=f"promo_manage_{promo.id}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_promocode_confirmed(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
code = promo.code
|
||||
success = await delete_promocode(db, promo)
|
||||
|
||||
if success:
|
||||
await callback.answer(f"✅ Промокод {code} удален", show_alert=True)
|
||||
await show_promocodes_list(callback, db_user, db)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка удаления промокода", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promocode_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
stats = await get_promocode_statistics(db, promo_id)
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика промокода</b> <code>{promo.code}</code>
|
||||
|
||||
📈 <b>Общая статистика:</b>
|
||||
- Всего использований: {stats['total_uses']}
|
||||
- Использований сегодня: {stats['today_uses']}
|
||||
- Осталось использований: {promo.max_uses - promo.current_uses}
|
||||
|
||||
📅 <b>Последние использования:</b>
|
||||
"""
|
||||
|
||||
if stats['recent_uses']:
|
||||
for use in stats['recent_uses'][:5]:
|
||||
use_date = format_datetime(use.used_at)
|
||||
|
||||
if hasattr(use, 'user_username') and use.user_username:
|
||||
user_display = f"@{use.user_username}"
|
||||
elif hasattr(use, 'user_full_name') and use.user_full_name:
|
||||
user_display = use.user_full_name
|
||||
elif hasattr(use, 'user_telegram_id'):
|
||||
user_display = f"ID{use.user_telegram_id}"
|
||||
else:
|
||||
user_display = f"ID{use.user_id}"
|
||||
|
||||
text += f"- {use_date} | {user_display}\n"
|
||||
else:
|
||||
text += "- Пока не было использований\n"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=f"promo_manage_{promo.id}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_promocodes_menu, F.data == "admin_promocodes")
|
||||
dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list")
|
||||
@@ -552,11 +978,22 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
|
||||
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))
|
||||
dp.callback_query.register(confirm_delete_promocode, F.data.startswith("promo_delete_"))
|
||||
dp.callback_query.register(delete_promocode_confirmed, F.data.startswith("promo_delete_confirm_"))
|
||||
dp.callback_query.register(show_promocode_stats, F.data.startswith("promo_stats_"))
|
||||
|
||||
dp.callback_query.register(start_edit_promocode_date, F.data.startswith("promo_edit_date_"))
|
||||
dp.callback_query.register(start_edit_promocode_amount, F.data.startswith("promo_edit_amount_"))
|
||||
dp.callback_query.register(start_edit_promocode_days, F.data.startswith("promo_edit_days_"))
|
||||
dp.callback_query.register(start_edit_promocode_uses, F.data.startswith("promo_edit_uses_"))
|
||||
|
||||
dp.callback_query.register(
|
||||
show_promocode_edit_menu,
|
||||
F.data.regexp(r"^promo_edit_\d+$")
|
||||
)
|
||||
|
||||
dp.callback_query.register(delete_promocode_confirmed, F.data.startswith("promo_delete_confirm_"))
|
||||
dp.callback_query.register(confirm_delete_promocode, F.data.startswith("promo_delete_"))
|
||||
|
||||
dp.message.register(process_promocode_code, AdminStates.creating_promocode)
|
||||
dp.message.register(process_promocode_value, AdminStates.setting_promocode_value)
|
||||
dp.message.register(process_promocode_uses, AdminStates.setting_promocode_uses)
|
||||
dp.message.register(process_promocode_expiry, AdminStates.setting_promocode_expiry)
|
||||
dp.message.register(process_promocode_expiry, AdminStates.setting_promocode_expiry)
|
||||
|
||||
@@ -306,13 +306,13 @@ async def show_pricing_settings(
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
|
||||
types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
|
||||
],
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
|
||||
# types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
|
||||
# ],
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
|
||||
# ],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
]
|
||||
@@ -405,8 +405,7 @@ async def show_countries_management(
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_countries"),
|
||||
types.InlineKeyboardButton(text="⚙️ API настройки", callback_data="admin_rw_api")
|
||||
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_countries")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Статистика нод", callback_data="admin_rw_nodes"),
|
||||
@@ -495,4 +494,4 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
handle_subscriptions_pagination,
|
||||
F.data.startswith("admin_subs_list_page_")
|
||||
)
|
||||
)
|
||||
|
||||
+11
-4
@@ -356,10 +356,15 @@ async def process_stars_payment_amount(
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await message.answer("❌ Оплата Stars временно недоступна")
|
||||
await message.answer("⚠ Оплата Stars временно недоступна")
|
||||
return
|
||||
|
||||
try:
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
invoice_link = await payment_service.create_stars_invoice(
|
||||
amount_kopeks=amount_kopeks,
|
||||
@@ -374,7 +379,9 @@ async def process_stars_payment_amount(
|
||||
|
||||
await message.answer(
|
||||
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
|
||||
f"Сумма: {texts.format_price(amount_kopeks)}\n\n"
|
||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
f"⭐ К оплате: {stars_amount} звезд\n"
|
||||
f"📊 Курс: {settings.get_stars_rate():.2f}₽ за звезду\n\n"
|
||||
f"Нажмите кнопку ниже для оплаты:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
@@ -384,7 +391,7 @@ async def process_stars_payment_amount(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
await message.answer("❌ Ошибка создания платежа")
|
||||
await message.answer("⚠ Ошибка создания платежа")
|
||||
|
||||
|
||||
@error_handler
|
||||
@@ -412,7 +419,7 @@ async def process_yookassa_payment_amount(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение баланса VPN на {settings.format_price(amount_kopeks)}",
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
receipt_email=None,
|
||||
receipt_phone=None,
|
||||
metadata={
|
||||
|
||||
@@ -19,7 +19,6 @@ async def show_main_menu(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
from datetime import datetime
|
||||
@@ -34,7 +33,6 @@ async def show_main_menu(
|
||||
|
||||
menu_text = texts.MAIN_MENU.format(
|
||||
user_name=db_user.full_name,
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
subscription_status=_get_subscription_status(db_user, texts)
|
||||
)
|
||||
|
||||
@@ -45,7 +43,8 @@ async def show_main_menu(
|
||||
is_admin=settings.is_admin(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
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks
|
||||
)
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -109,7 +108,7 @@ async def handle_back_to_menu(
|
||||
|
||||
def _get_subscription_status(user: User, texts) -> str:
|
||||
if not user.subscription:
|
||||
return "⌐ Отсутствует"
|
||||
return "❌ Отсутствует"
|
||||
|
||||
subscription = user.subscription
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
@@ -85,13 +85,7 @@ async def create_invite_message(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📤 Поделиться",
|
||||
switch_inline_query=invite_text
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📋 Скопировать ссылку",
|
||||
callback_data="copy_referral_link"
|
||||
switch_inline_query=invite_text
|
||||
)
|
||||
],
|
||||
[
|
||||
@@ -104,7 +98,7 @@ async def create_invite_message(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 <b>Приглашение создано!</b>\n\n"
|
||||
f"Используйте кнопку ниже для отправки приглашения или скопируйте текст:\n\n"
|
||||
f"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:\n\n"
|
||||
f"<code>{invite_text}</code>",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
@@ -112,20 +106,6 @@ async def create_invite_message(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def copy_referral_link(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
|
||||
|
||||
await callback.answer(
|
||||
f"Ссылка скопирована: {referral_link}",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
@@ -137,8 +117,3 @@ def register_handlers(dp: Dispatcher):
|
||||
create_invite_message,
|
||||
F.data == "referral_create_invite"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
copy_referral_link,
|
||||
F.data == "copy_referral_link"
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith("balance_"):
|
||||
logger.warning(f"Невалидный payload: {query.invoice_payload}")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Ошибка валидации платежа. Попробуйте еще раз."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.database.database import get_db
|
||||
async for db in get_db():
|
||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||
if not user:
|
||||
logger.warning(f"Пользователь {query.from_user.id} не найден в БД")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Пользователь не найден. Обратитесь в поддержку."
|
||||
)
|
||||
return
|
||||
break
|
||||
except Exception as db_error:
|
||||
logger.error(f"Ошибка подключения к БД в pre_checkout_query: {db_error}")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Техническая ошибка. Попробуйте позже."
|
||||
)
|
||||
return
|
||||
|
||||
await query.answer(ok=True)
|
||||
logger.info(f"✅ Pre-checkout одобрен для пользователя {query.from_user.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в pre_checkout_query: {e}", exc_info=True)
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Техническая ошибка. Попробуйте позже."
|
||||
)
|
||||
|
||||
|
||||
async def handle_successful_payment(
|
||||
message: types.Message,
|
||||
db: AsyncSession,
|
||||
**kwargs
|
||||
):
|
||||
try:
|
||||
payment = message.successful_payment
|
||||
user_id = message.from_user.id
|
||||
|
||||
logger.info(
|
||||
f"💳 Успешный Stars платеж от {user_id}: "
|
||||
f"{payment.total_amount} XTR, "
|
||||
f"payload: {payment.invoice_payload}, "
|
||||
f"charge_id: {payment.telegram_payment_charge_id}"
|
||||
)
|
||||
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {user_id} не найден при обработке Stars платежа")
|
||||
await message.answer(
|
||||
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку."
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
success = await payment_service.process_stars_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
stars_amount=payment.total_amount,
|
||||
payload=payment.invoice_payload,
|
||||
telegram_payment_charge_id=payment.telegram_payment_charge_id
|
||||
)
|
||||
|
||||
if success:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
||||
|
||||
await message.answer(
|
||||
f"🎉 <b>Платеж успешно обработан!</b>\n\n"
|
||||
f"⭐ Потрачено звезд: {payment.total_amount}\n"
|
||||
f"💰 Зачислено на баланс: {rubles_amount:.2f} ₽\n"
|
||||
f"🆔 ID транзакции: {payment.telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Спасибо за пополнение! 🚀",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✅ Stars платеж успешно обработан: "
|
||||
f"пользователь {user.id}, {payment.total_amount} звезд → {rubles_amount:.2f}₽"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при зачислении средств. "
|
||||
"Обратитесь в поддержку, платеж будет проверен вручную."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в successful_payment: {e}", exc_info=True)
|
||||
await message.answer(
|
||||
"❌ Техническая ошибка при обработке платежа. "
|
||||
"Обратитесь в поддержку для решения проблемы."
|
||||
)
|
||||
|
||||
|
||||
def register_stars_handlers(dp: Dispatcher):
|
||||
|
||||
dp.pre_checkout_query.register(
|
||||
handle_pre_checkout_query,
|
||||
F.currency == "XTR"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
handle_successful_payment,
|
||||
F.successful_payment
|
||||
)
|
||||
|
||||
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
|
||||
+242
-25
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -20,6 +21,62 @@ from app.utils.user_utils import generate_unique_referral_code
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_potential_referral_code(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"🔍 REFERRAL CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
|
||||
|
||||
if current_state not in [
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state,
|
||||
None
|
||||
]:
|
||||
return False
|
||||
|
||||
user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
if user and user.status == UserStatus.ACTIVE.value:
|
||||
return False
|
||||
|
||||
potential_code = message.text.strip()
|
||||
if len(potential_code) < 4 or len(potential_code) > 20:
|
||||
return False
|
||||
|
||||
referrer = await get_user_by_referral_code(db, potential_code)
|
||||
if not referrer:
|
||||
await message.answer(
|
||||
"❌ Неверный реферальный код.\n\n"
|
||||
"💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n"
|
||||
"⏭️ Для продолжения регистрации без реферального кода используйте команду /start"
|
||||
)
|
||||
return True
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['referral_code'] = potential_code
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
|
||||
await message.answer("✅ Реферальный код принят!")
|
||||
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
|
||||
|
||||
if current_state != RegistrationStates.waiting_for_referral_code.state:
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
|
||||
await message.answer(
|
||||
texts.RULES_TEXT,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 Правила отправлены после ввода реферального кода")
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
|
||||
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
|
||||
|
||||
@@ -36,6 +93,37 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
logger.info(f"✅ Активный пользователь найден: {user.telegram_id}")
|
||||
|
||||
profile_updated = False
|
||||
|
||||
if user.username != message.from_user.username:
|
||||
old_username = user.username
|
||||
user.username = message.from_user.username
|
||||
logger.info(f"📝 Username обновлен: '{old_username}' → '{user.username}'")
|
||||
profile_updated = True
|
||||
|
||||
if user.first_name != message.from_user.first_name:
|
||||
old_first_name = user.first_name
|
||||
user.first_name = message.from_user.first_name
|
||||
logger.info(f"📝 Имя обновлено: '{old_first_name}' → '{user.first_name}'")
|
||||
profile_updated = True
|
||||
|
||||
if user.last_name != message.from_user.last_name:
|
||||
old_last_name = user.last_name
|
||||
user.last_name = message.from_user.last_name
|
||||
logger.info(f"📝 Фамилия обновлена: '{old_last_name}' → '{user.last_name}'")
|
||||
profile_updated = True
|
||||
|
||||
user.last_activity = datetime.utcnow()
|
||||
|
||||
if profile_updated:
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
logger.info(f"💾 Профиль пользователя {user.telegram_id} обновлен")
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
if referral_code and not user.referred_by_id:
|
||||
@@ -50,7 +138,6 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user.full_name,
|
||||
balance=texts.format_price(user.balance_kopeks),
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
@@ -58,7 +145,8 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
@@ -107,6 +195,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
user.remnawave_uuid = None
|
||||
user.has_had_paid_subscription = False
|
||||
user.referred_by_id = None
|
||||
|
||||
user.username = message.from_user.username
|
||||
user.first_name = message.from_user.first_name
|
||||
user.last_name = message.from_user.last_name
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.last_activity = datetime.utcnow()
|
||||
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
user.referral_code = await generate_unique_referral_code(db, user.telegram_id)
|
||||
|
||||
@@ -285,6 +380,7 @@ async def process_referral_code_skip(
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
|
||||
|
||||
async def complete_registration_from_callback(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
@@ -299,6 +395,10 @@ async def complete_registration_from_callback(
|
||||
logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.")
|
||||
texts = get_texts(existing_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await callback.message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
@@ -306,21 +406,24 @@ async def complete_registration_from_callback(
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
|
||||
user_name = existing_user.full_name
|
||||
user_telegram_id = existing_user.telegram_id
|
||||
user_language = existing_user.language
|
||||
has_had_paid_subscription = existing_user.has_had_paid_subscription
|
||||
balance_kopeks = existing_user.balance_kopeks
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
balance=texts.format_price(balance_kopeks),
|
||||
subscription_status=_get_subscription_status(existing_user, texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=existing_user.language,
|
||||
is_admin=settings.is_admin(existing_user.telegram_id),
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -362,7 +465,7 @@ async def complete_registration_from_callback(
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен")
|
||||
|
||||
else:
|
||||
elif not existing_user:
|
||||
logger.info(f"🆕 Создаем нового пользователя {callback.from_user.id}")
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
|
||||
@@ -377,6 +480,20 @@ async def complete_registration_from_callback(
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
else:
|
||||
logger.info(f"🔄 Обновляем существующего пользователя {callback.from_user.id}")
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.language = language
|
||||
if referrer_id and not existing_user.referred_by_id:
|
||||
existing_user.referred_by_id = referrer_id
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
user = existing_user
|
||||
|
||||
if referrer_id:
|
||||
try:
|
||||
@@ -390,7 +507,6 @@ async def complete_registration_from_callback(
|
||||
|
||||
has_active_subscription = False
|
||||
subscription_is_active = False
|
||||
|
||||
user_name = user.full_name
|
||||
balance_kopeks = user.balance_kopeks
|
||||
user_telegram_id = user.telegram_id
|
||||
@@ -401,7 +517,6 @@ async def complete_registration_from_callback(
|
||||
await callback.message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
balance=texts.format_price(balance_kopeks),
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
@@ -409,7 +524,8 @@ async def complete_registration_from_callback(
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
|
||||
@@ -419,24 +535,25 @@ async def complete_registration_from_callback(
|
||||
balance_rubles = balance_kopeks / 100
|
||||
await callback.message.answer(
|
||||
f"Добро пожаловать, {user_name}!\n"
|
||||
f"Баланс: {balance_rubles} ₽\n"
|
||||
f"Баланс: {balance_rubles:.2f} ₽\n"
|
||||
f"Подписка: Нет активной подписки",
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"❌ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
try:
|
||||
await callback.message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
|
||||
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as final_error:
|
||||
logger.error(f"❌ Критическая ошибка при отправке простого сообщения: {final_error}")
|
||||
logger.error(f"⛔ Критическая ошибка при отправке простого сообщения: {final_error}")
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user_telegram_id}")
|
||||
|
||||
@@ -451,6 +568,48 @@ async def complete_registration(
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
|
||||
logger.warning(f"⚠️ Пользователь {message.from_user.id} уже активен! Показываем главное меню.")
|
||||
texts = get_texts(existing_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if existing_user.subscription:
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
|
||||
user_name = existing_user.full_name
|
||||
user_telegram_id = existing_user.telegram_id
|
||||
user_language = existing_user.language
|
||||
has_had_paid_subscription = existing_user.has_had_paid_subscription
|
||||
balance_kopeks = existing_user.balance_kopeks
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
subscription_status=_get_subscription_status(existing_user, texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
|
||||
await message.answer(f"Добро пожаловать, {user_name}!")
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
@@ -483,7 +642,7 @@ async def complete_registration(
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {message.from_user.id} восстановлен")
|
||||
|
||||
else:
|
||||
elif not existing_user:
|
||||
logger.info(f"🆕 Создаем нового пользователя {message.from_user.id}")
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, message.from_user.id)
|
||||
@@ -498,6 +657,20 @@ async def complete_registration(
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
else:
|
||||
logger.info(f"🔄 Обновляем существующего пользователя {message.from_user.id}")
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.language = language
|
||||
if referrer_id and not existing_user.referred_by_id:
|
||||
existing_user.referred_by_id = referrer_id
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
user = existing_user
|
||||
|
||||
if referrer_id:
|
||||
try:
|
||||
@@ -511,7 +684,6 @@ async def complete_registration(
|
||||
|
||||
has_active_subscription = False
|
||||
subscription_is_active = False
|
||||
|
||||
user_name = user.full_name
|
||||
balance_kopeks = user.balance_kopeks
|
||||
user_telegram_id = user.telegram_id
|
||||
@@ -522,7 +694,6 @@ async def complete_registration(
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
balance=texts.format_price(balance_kopeks),
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
@@ -530,7 +701,8 @@ async def complete_registration(
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
|
||||
@@ -540,19 +712,20 @@ async def complete_registration(
|
||||
balance_rubles = balance_kopeks / 100
|
||||
await message.answer(
|
||||
f"Добро пожаловать, {user_name}!\n"
|
||||
f"Баланс: {balance_rubles} ₽\n"
|
||||
f"Баланс: {balance_rubles:.2f} ₽\n"
|
||||
f"Подписка: Нет активной подписки",
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"❌ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
try:
|
||||
await message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
|
||||
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
|
||||
@@ -563,9 +736,44 @@ async def complete_registration(
|
||||
|
||||
|
||||
def _get_subscription_status(user, texts):
|
||||
if user.subscription and user.subscription.is_active:
|
||||
return getattr(texts, 'SUBSCRIPTION_ACTIVE', 'Активна')
|
||||
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
|
||||
if not user or not hasattr(user, 'subscription'):
|
||||
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
|
||||
|
||||
if not user.subscription:
|
||||
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
|
||||
|
||||
subscription = user.subscription
|
||||
|
||||
from datetime import datetime
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if hasattr(subscription, 'end_date') and subscription.end_date <= current_time:
|
||||
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
|
||||
|
||||
if hasattr(subscription, 'end_date'):
|
||||
days_left = (subscription.end_date - current_time).days
|
||||
else:
|
||||
days_left = 0
|
||||
|
||||
is_trial = getattr(subscription, 'is_trial', False)
|
||||
|
||||
if is_trial:
|
||||
if days_left > 1:
|
||||
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
|
||||
elif days_left == 1:
|
||||
return "🎁 Тестовая подписка\n⚠️ истекает завтра!"
|
||||
else:
|
||||
return "🎁 Тестовая подписка\n⚠️ истекает сегодня!"
|
||||
else:
|
||||
if days_left > 7:
|
||||
return f"💎 Активна\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
|
||||
elif days_left > 1:
|
||||
return f"💎 Активна\n⚠️ истекает через {days_left} дн."
|
||||
elif days_left == 1:
|
||||
return "💎 Активна\n⚠️ истекает завтра!"
|
||||
else:
|
||||
return "💎 Активна\n⚠️ истекает сегодня!"
|
||||
|
||||
|
||||
|
||||
def _get_subscription_status_simple(texts):
|
||||
@@ -614,4 +822,13 @@ def register_handlers(dp: Dispatcher):
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_referral_code_input")
|
||||
|
||||
dp.message.register(
|
||||
handle_potential_referral_code,
|
||||
StateFilter(
|
||||
RegistrationStates.waiting_for_rules_accept,
|
||||
RegistrationStates.waiting_for_referral_code
|
||||
)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован handle_potential_referral_code")
|
||||
|
||||
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
|
||||
|
||||
+380
-72
@@ -8,7 +8,7 @@ import json
|
||||
import os
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
|
||||
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
|
||||
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,
|
||||
@@ -43,6 +43,7 @@ from app.services.referral_service import process_referral_purchase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRAFFIC_PRICES = get_traffic_prices()
|
||||
|
||||
async def show_subscription_info(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -80,7 +81,7 @@ async def show_subscription_info(
|
||||
if subscription.is_trial:
|
||||
actual_status = "trial_active"
|
||||
status_display = "Тестовая"
|
||||
status_emoji = "🎁"
|
||||
status_emoji = "🎯"
|
||||
else:
|
||||
actual_status = "paid_active"
|
||||
status_display = "Активна"
|
||||
@@ -116,26 +117,61 @@ async def show_subscription_info(
|
||||
subscription_type = "Триал" if subscription.is_trial else "Платная"
|
||||
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
traffic_used_display = f"∞ (безлимит) / {subscription.traffic_used_gb:.1f} ГБ"
|
||||
traffic_used_display = f"∞ (безлимит) | Использовано: {subscription.traffic_used_gb:.1f} ГБ"
|
||||
else:
|
||||
traffic_used_display = f"{subscription.traffic_used_gb:.1f} / {subscription.traffic_limit_gb} ГБ"
|
||||
|
||||
devices_used = await get_current_devices_count(db_user)
|
||||
|
||||
devices_used_str = "—"
|
||||
devices_list = []
|
||||
devices_count = 0
|
||||
|
||||
try:
|
||||
if db_user.remnawave_uuid:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
service = RemnaWaveService()
|
||||
|
||||
async with service.api as api:
|
||||
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
|
||||
|
||||
if response and 'response' in response:
|
||||
devices_info = response['response']
|
||||
devices_count = devices_info.get('total', 0)
|
||||
devices_list = devices_info.get('devices', [])
|
||||
devices_used_str = str(devices_count)
|
||||
logger.info(f"Найдено {devices_count} устройств для пользователя {db_user.telegram_id}")
|
||||
else:
|
||||
logger.warning(f"Не удалось получить информацию об устройствах для {db_user.telegram_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения устройств для отображения: {e}")
|
||||
devices_used_str = await get_current_devices_count(db_user)
|
||||
|
||||
servers_names = await get_servers_display_names(subscription.connected_squads)
|
||||
servers_display = servers_names if servers_names else "Нет серверов"
|
||||
|
||||
message = f"""👤 {db_user.full_name}
|
||||
━━━━━━━━━━━━━━━━━
|
||||
💰 Баланс: {settings.format_price(db_user.balance_kopeks)}
|
||||
📱 Подписка: {status_emoji} {status_display}{warning_text}
|
||||
━━━━━━━━━━━━━━━━━
|
||||
|
||||
📱 Информация о подписке
|
||||
🎭 Тип: {subscription_type}
|
||||
📅 Действует до: {subscription.end_date.strftime("%d.%m.%Y %H:%M")}
|
||||
⏰ Осталось: {time_left_text}
|
||||
📈 Трафик: {traffic_used_display}
|
||||
🌍 Серверы: {len(subscription.connected_squads)} стран
|
||||
📱 Устройства: {devices_used} / {subscription.device_limit}"""
|
||||
🌍 Серверы: {servers_display}
|
||||
📱 Устройства: {devices_used_str} / {subscription.device_limit}"""
|
||||
|
||||
if devices_list and len(devices_list) > 0:
|
||||
message += f"\n\n<blockquote>📱 <b>Подключенные устройства:</b>\n"
|
||||
for device in devices_list[:5]:
|
||||
platform = device.get('platform', 'Unknown')
|
||||
device_model = device.get('deviceModel', 'Unknown')
|
||||
device_info = f"{platform} - {device_model}"
|
||||
|
||||
if len(device_info) > 35:
|
||||
device_info = device_info[:32] + "..."
|
||||
message += f"• {device_info}\n"
|
||||
message += "</blockquote>"
|
||||
|
||||
if hasattr(subscription, 'subscription_url') and subscription.subscription_url:
|
||||
if actual_status in ['trial_active', 'paid_active']:
|
||||
@@ -154,6 +190,64 @@ async def show_subscription_info(
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
async def get_current_devices_detailed(db_user: User) -> dict:
|
||||
try:
|
||||
if not db_user.remnawave_uuid:
|
||||
return {"count": 0, "devices": []}
|
||||
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
service = RemnaWaveService()
|
||||
|
||||
async with service.api as api:
|
||||
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
|
||||
|
||||
if response and 'response' in response:
|
||||
devices_info = response['response']
|
||||
total_devices = devices_info.get('total', 0)
|
||||
devices_list = devices_info.get('devices', [])
|
||||
|
||||
return {
|
||||
"count": total_devices,
|
||||
"devices": devices_list[:5]
|
||||
}
|
||||
else:
|
||||
return {"count": 0, "devices": []}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения детальной информации об устройствах: {e}")
|
||||
return {"count": 0, "devices": []}
|
||||
|
||||
async def get_servers_display_names(squad_uuids: List[str]) -> str:
|
||||
"""
|
||||
Получает отображаемые названия серверов по их UUID
|
||||
"""
|
||||
if not squad_uuids:
|
||||
return "Нет серверов"
|
||||
|
||||
try:
|
||||
countries = await _get_available_countries()
|
||||
|
||||
server_names = []
|
||||
for uuid in squad_uuids:
|
||||
for country in countries:
|
||||
if country['uuid'] == uuid:
|
||||
server_names.append(country['name'])
|
||||
break
|
||||
|
||||
if not server_names:
|
||||
return f"{len(squad_uuids)} стран"
|
||||
|
||||
if len(server_names) > 6:
|
||||
displayed = ", ".join(server_names[:6])
|
||||
remaining = len(server_names) - 6
|
||||
return f"{displayed} и ещё {remaining}"
|
||||
else:
|
||||
return ", ".join(server_names)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения названий серверов: {e}")
|
||||
return f"{len(squad_uuids)} стран"
|
||||
|
||||
async def get_current_devices_count(db_user: User) -> str:
|
||||
try:
|
||||
if not db_user.remnawave_uuid:
|
||||
@@ -181,7 +275,7 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
if subscription.is_trial:
|
||||
return 0
|
||||
|
||||
from app.config import TRAFFIC_PRICES, PERIOD_PRICES, settings
|
||||
from app.config import settings
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
@@ -197,8 +291,8 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
|
||||
traffic_cost = TRAFFIC_PRICES.get(subscription.traffic_limit_gb, 0)
|
||||
devices_cost = max(0, subscription.device_limit - 1) * settings.PRICE_PER_DEVICE
|
||||
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
|
||||
|
||||
@@ -212,7 +306,7 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
return total_cost
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка расчета стоимости подписки: {e}")
|
||||
logger.error(f"⚠️ Ошибка расчета стоимости подписки: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -328,7 +422,7 @@ async def start_subscription_purchase(
|
||||
initial_data = {
|
||||
'period_days': None,
|
||||
'countries': [],
|
||||
'devices': 1,
|
||||
'devices': settings.DEFAULT_DEVICE_LIMIT,
|
||||
'total_price': 0
|
||||
}
|
||||
|
||||
@@ -349,6 +443,9 @@ async def handle_add_countries(
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
if not await _should_show_countries_management():
|
||||
await callback.answer("ℹ️ Управление серверами недоступно - доступен только один сервер", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
@@ -366,7 +463,7 @@ async def handle_add_countries(
|
||||
current_countries_names.append(country['name'])
|
||||
|
||||
text = "🌍 <b>Управление странами подписки</b>\n\n"
|
||||
text += f"📍 <b>Текущие страны ({len(current_countries)}):</b>\n"
|
||||
text += f"📋 <b>Текущие страны ({len(current_countries)}):</b>\n"
|
||||
if current_countries_names:
|
||||
text += "\n".join(f"• {name}" for name in current_countries_names)
|
||||
else:
|
||||
@@ -409,11 +506,11 @@ async def get_countries_price_by_uuids_fallback(country_uuids: List[str], db: As
|
||||
total_price += price
|
||||
prices_list.append(price)
|
||||
else:
|
||||
default_price = 1000
|
||||
default_price = 0
|
||||
total_price += default_price
|
||||
prices_list.append(default_price)
|
||||
except Exception:
|
||||
default_price = 1000
|
||||
default_price = 0
|
||||
total_price += default_price
|
||||
prices_list.append(default_price)
|
||||
|
||||
@@ -421,7 +518,7 @@ async def get_countries_price_by_uuids_fallback(country_uuids: List[str], db: As
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка fallback функции: {e}")
|
||||
default_prices = [1000] * len(country_uuids)
|
||||
default_prices = [0] * len(country_uuids)
|
||||
return sum(default_prices), default_prices
|
||||
|
||||
async def handle_manage_country(
|
||||
@@ -782,10 +879,14 @@ async def confirm_add_traffic(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
price = TRAFFIC_PRICES[traffic_gb]
|
||||
price = settings.get_traffic_price(traffic_gb)
|
||||
|
||||
if price == 0 and traffic_gb != 0:
|
||||
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
|
||||
return
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
await callback.answer("⌛ Недостаточно средств на балансе", show_alert=True)
|
||||
await callback.answer("⚠️ Недостаточно средств на балансе", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -795,7 +896,7 @@ async def confirm_add_traffic(
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer("⌛ Ошибка списания средств", show_alert=True)
|
||||
await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
if traffic_gb == 0:
|
||||
@@ -850,6 +951,11 @@ async def confirm_add_traffic(
|
||||
|
||||
await callback.answer()
|
||||
|
||||
def update_traffic_prices():
|
||||
from app.config import refresh_traffic_prices
|
||||
refresh_traffic_prices()
|
||||
logger.info("🔄 TRAFFIC_PRICES обновлены из конфигурации")
|
||||
|
||||
|
||||
async def confirm_add_devices(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -861,10 +967,20 @@ async def confirm_add_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
new_total_devices = subscription.device_limit + devices_count
|
||||
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and new_total_devices > settings.MAX_DEVICES_LIMIT:
|
||||
await callback.answer(
|
||||
f"⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT}). "
|
||||
f"У вас: {subscription.device_limit}, добавляете: {devices_count}",
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
price = devices_count * settings.PRICE_PER_DEVICE
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
await callback.answer("❌ Недостаточно средств на балансе", show_alert=True)
|
||||
await callback.answer("⚠️ Недостаточно средств на балансе", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -874,7 +990,7 @@ async def confirm_add_devices(
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer("❌ Ошибка списания средств", show_alert=True)
|
||||
await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
await add_subscription_devices(db, subscription, devices_count)
|
||||
@@ -1143,27 +1259,96 @@ async def select_period(
|
||||
data['total_price'] = PERIOD_PRICES[period_days]
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
fixed_traffic_price = TRAFFIC_PRICES.get(settings.get_fixed_traffic_limit(), 0)
|
||||
fixed_traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
|
||||
data['total_price'] += fixed_traffic_price
|
||||
data['traffic_gb'] = settings.get_fixed_traffic_limit()
|
||||
|
||||
await state.set_data(data)
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
available_packages = [pkg for pkg in settings.get_traffic_packages() if pkg['enabled']]
|
||||
|
||||
if not available_packages:
|
||||
await callback.answer("⚠️ Пакеты трафика не настроены", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_TRAFFIC,
|
||||
reply_markup=get_traffic_packages_keyboard(db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_traffic)
|
||||
else:
|
||||
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)
|
||||
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()
|
||||
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)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_DEVICES,
|
||||
reply_markup=get_devices_keyboard(1, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def refresh_traffic_config():
|
||||
try:
|
||||
from app.config import refresh_traffic_prices
|
||||
refresh_traffic_prices()
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
enabled_count = sum(1 for pkg in packages if pkg['enabled'])
|
||||
|
||||
logger.info(f"🔄 Конфигурация трафика обновлена: {enabled_count} активных пакетов")
|
||||
for pkg in packages:
|
||||
if pkg['enabled']:
|
||||
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
|
||||
logger.info(f" 📦 {gb_text}: {pkg['price']/100}₽")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠️ Ошибка обновления конфигурации трафика: {e}")
|
||||
return False
|
||||
|
||||
async def get_traffic_packages_info() -> str:
|
||||
try:
|
||||
packages = settings.get_traffic_packages()
|
||||
|
||||
info_lines = ["📦 Настроенные пакеты трафика:"]
|
||||
|
||||
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
|
||||
disabled_packages = [pkg for pkg in packages if not pkg['enabled']]
|
||||
|
||||
if enabled_packages:
|
||||
info_lines.append("\n✅ Активные:")
|
||||
for pkg in enabled_packages:
|
||||
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
|
||||
info_lines.append(f" • {gb_text}: {pkg['price']/100}₽")
|
||||
|
||||
if disabled_packages:
|
||||
info_lines.append("\n❌ Отключенные:")
|
||||
for pkg in disabled_packages:
|
||||
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
|
||||
info_lines.append(f" • {gb_text}: {pkg['price']/100}₽")
|
||||
|
||||
info_lines.append(f"\n📊 Всего пакетов: {len(packages)}")
|
||||
info_lines.append(f"🟢 Активных: {len(enabled_packages)}")
|
||||
info_lines.append(f"🔴 Отключенных: {len(disabled_packages)}")
|
||||
|
||||
return "\n".join(info_lines)
|
||||
|
||||
except Exception as e:
|
||||
return f"⚠️ Ошибка получения информации: {e}"
|
||||
|
||||
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
|
||||
|
||||
devices_used = await get_current_devices_count(db_user)
|
||||
@@ -1237,23 +1422,36 @@ async def select_traffic(
|
||||
state: FSMContext,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
traffic_gb = int(callback.data.split('_')[1])
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
data['traffic_gb'] = traffic_gb
|
||||
data['total_price'] += TRAFFIC_PRICES[traffic_gb]
|
||||
|
||||
traffic_price = settings.get_traffic_price(traffic_gb)
|
||||
data['total_price'] += traffic_price
|
||||
|
||||
await state.set_data(data)
|
||||
|
||||
countries = await _get_available_countries()
|
||||
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()
|
||||
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)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_DEVICES,
|
||||
reply_markup=get_devices_keyboard(1, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, [], db_user.language)
|
||||
)
|
||||
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -1263,7 +1461,6 @@ async def select_country(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
country_uuid = callback.data.split('_')[1]
|
||||
data = await state.get_data()
|
||||
|
||||
@@ -1275,7 +1472,7 @@ async def select_country(
|
||||
|
||||
countries = await _get_available_countries()
|
||||
|
||||
base_price = PERIOD_PRICES[data['period_days']] + TRAFFIC_PRICES[data['traffic_gb']]
|
||||
base_price = PERIOD_PRICES[data['period_days']] + settings.get_traffic_price(data['traffic_gb'])
|
||||
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
@@ -1335,7 +1532,7 @@ async def select_devices(
|
||||
|
||||
base_price = (
|
||||
PERIOD_PRICES[data['period_days']] +
|
||||
TRAFFIC_PRICES[data['traffic_gb']]
|
||||
settings.get_traffic_price(data['traffic_gb'])
|
||||
)
|
||||
|
||||
countries = await _get_available_countries()
|
||||
@@ -1344,7 +1541,7 @@ async def select_devices(
|
||||
if c['uuid'] in data['countries']
|
||||
)
|
||||
|
||||
devices_price = (devices - 1) * settings.PRICE_PER_DEVICE
|
||||
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
data['devices'] = devices
|
||||
data['total_price'] = base_price + countries_price + devices_price
|
||||
@@ -1362,7 +1559,7 @@ async def devices_continue(
|
||||
db: AsyncSession
|
||||
):
|
||||
if not callback.data == "devices_continue":
|
||||
await callback.answer("⌛ Некорректный запрос", show_alert=True)
|
||||
await callback.answer("⚠️ Некорректный запрос", show_alert=True)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
@@ -1385,21 +1582,23 @@ async def devices_continue(
|
||||
base_price = PERIOD_PRICES[data['period_days']]
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_price = TRAFFIC_PRICES.get(data['traffic_gb'], 0)
|
||||
traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
|
||||
final_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
else:
|
||||
traffic_price = TRAFFIC_PRICES.get(data['traffic_gb'], 0)
|
||||
traffic_price = settings.get_traffic_price(data['traffic_gb'])
|
||||
final_traffic_gb = data['traffic_gb']
|
||||
|
||||
devices_price = (data['devices'] - 1) * settings.PRICE_PER_DEVICE
|
||||
devices_price = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
total_price = base_price + traffic_price + countries_price + devices_price
|
||||
|
||||
data['total_price'] = total_price
|
||||
await state.set_data(data)
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
if data['traffic_gb'] == 0:
|
||||
if final_traffic_gb == 0:
|
||||
traffic_display = "Безлимитный"
|
||||
else:
|
||||
traffic_display = f"{data['traffic_gb']} ГБ"
|
||||
traffic_display = f"{final_traffic_gb} ГБ"
|
||||
else:
|
||||
if data['traffic_gb'] == 0:
|
||||
traffic_display = "Безлимитный"
|
||||
@@ -1449,13 +1648,13 @@ async def confirm_purchase(
|
||||
countries_price += country['price_kopeks']
|
||||
server_prices.append(country['price_kopeks'])
|
||||
|
||||
devices_price = (data['devices'] - 1) * settings.PRICE_PER_DEVICE
|
||||
devices_price = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_price = TRAFFIC_PRICES.get(settings.get_fixed_traffic_limit(), 0)
|
||||
traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
|
||||
final_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
else:
|
||||
traffic_price = TRAFFIC_PRICES.get(data['traffic_gb'], 0)
|
||||
traffic_price = settings.get_traffic_price(data['traffic_gb'])
|
||||
final_traffic_gb = data['traffic_gb']
|
||||
|
||||
final_price = base_price + traffic_price + countries_price + devices_price
|
||||
@@ -1543,7 +1742,7 @@ async def confirm_purchase(
|
||||
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
|
||||
|
||||
if not remnawave_user:
|
||||
logger.error(f"❌ Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}")
|
||||
logger.error(f"⚠️ Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}")
|
||||
logger.info(f"🔄 Fallback: принудительное создание нового RemnaWave пользователя")
|
||||
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
|
||||
|
||||
@@ -1609,6 +1808,91 @@ async def confirm_purchase(
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
|
||||
async def add_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
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)
|
||||
|
||||
if price == 0 and traffic_gb != 0:
|
||||
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
|
||||
return
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
await callback.answer("⚠️ Недостаточно средств на балансе", show_alert=True)
|
||||
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:
|
||||
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,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f"Добавление {traffic_gb} ГБ трафика"
|
||||
)
|
||||
|
||||
try:
|
||||
await process_referral_purchase(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
purchase_amount_kopeks=price,
|
||||
transaction_id=None
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферальной покупки: {e}")
|
||||
|
||||
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,
|
||||
@@ -1641,6 +1925,15 @@ async def create_paid_subscription_with_traffic_mode(
|
||||
|
||||
return subscription
|
||||
|
||||
def validate_traffic_price(gb: int) -> bool:
|
||||
from app.config import settings
|
||||
|
||||
price = settings.get_traffic_price(gb)
|
||||
if gb == 0:
|
||||
return True
|
||||
|
||||
return price > 0
|
||||
|
||||
|
||||
async def handle_subscription_settings(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -1667,9 +1960,11 @@ async def handle_subscription_settings(
|
||||
Выберите что хотите изменить:
|
||||
"""
|
||||
|
||||
show_countries = await _should_show_countries_management()
|
||||
|
||||
await callback.message.edit_text(
|
||||
settings_text,
|
||||
reply_markup=get_subscription_settings_keyboard(db_user.language),
|
||||
reply_markup=get_subscription_settings_keyboard(db_user.language, show_countries),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -1858,7 +2153,7 @@ async def _get_available_countries():
|
||||
countries.append({
|
||||
"uuid": squad["uuid"],
|
||||
"name": squad_name,
|
||||
"price_kopeks": 1000,
|
||||
"price_kopeks": 0,
|
||||
"is_available": True
|
||||
})
|
||||
|
||||
@@ -1989,6 +2284,15 @@ async def handle_add_country_to_subscription(
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def _should_show_countries_management() -> bool:
|
||||
try:
|
||||
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:
|
||||
logger.error(f"Ошибка проверки доступных серверов: {e}")
|
||||
return True
|
||||
|
||||
|
||||
async def confirm_add_countries_to_subscription(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -2207,7 +2511,7 @@ async def handle_connect_subscription(
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not subscription or not subscription.subscription_url:
|
||||
await callback.answer("❌ У вас нет активной подписки или ссылка еще генерируется", show_alert=True)
|
||||
await callback.answer("⚠ У вас нет активной подписки или ссылка еще генерируется", show_alert=True)
|
||||
return
|
||||
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
@@ -2216,13 +2520,10 @@ async def handle_connect_subscription(
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Открыть подписку",
|
||||
text="🔗 Открыть подписку в мини-приложении",
|
||||
web_app=types.WebAppInfo(url=subscription.subscription_url)
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Показать ссылку", callback_data="open_subscription_link")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
|
||||
]
|
||||
@@ -2230,9 +2531,9 @@ async def handle_connect_subscription(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"""
|
||||
🔗 <b>Подключить подписку</b>
|
||||
📱 <b>Подключить подписку</b>
|
||||
|
||||
📱 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:
|
||||
🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:
|
||||
""",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
@@ -2240,7 +2541,7 @@ async def handle_connect_subscription(
|
||||
|
||||
elif connect_mode == "miniapp_custom":
|
||||
if not settings.MINIAPP_CUSTOM_URL:
|
||||
await callback.answer("❌ Кастомная ссылка для мини-приложения не настроена", show_alert=True)
|
||||
await callback.answer("⚠ Кастомная ссылка для мини-приложения не настроена", show_alert=True)
|
||||
return
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -2250,9 +2551,6 @@ async def handle_connect_subscription(
|
||||
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Показать ссылку подписки", callback_data="open_subscription_link")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
|
||||
]
|
||||
@@ -2263,9 +2561,6 @@ async def handle_connect_subscription(
|
||||
🚀 <b>Подключить подписку</b>
|
||||
|
||||
📱 Нажмите кнопку ниже, чтобы открыть приложение:
|
||||
|
||||
📋 <b>Ссылка подписки:</b>
|
||||
<code>{subscription.subscription_url}</code>
|
||||
""",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
@@ -2428,6 +2723,16 @@ async def handle_specific_app_guide(
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
async def handle_no_traffic_packages(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
await callback.answer(
|
||||
"⚠️ В данный момент нет доступных пакетов трафика. "
|
||||
"Обратитесь в техподдержку для получения информации.",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
|
||||
async def handle_open_subscription_link(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -2539,6 +2844,7 @@ def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMa
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
update_traffic_prices()
|
||||
|
||||
dp.callback_query.register(
|
||||
show_subscription_info,
|
||||
@@ -2560,7 +2866,6 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.in_(["menu_buy", "subscription_upgrade"])
|
||||
)
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_add_countries,
|
||||
F.data == "subscription_add_countries"
|
||||
@@ -2616,7 +2921,6 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "confirm_reset_devices"
|
||||
)
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
select_period,
|
||||
F.data.startswith("period_"),
|
||||
@@ -2647,7 +2951,6 @@ def register_handlers(dp: Dispatcher):
|
||||
SubscriptionStates.confirming_purchase
|
||||
)
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_autopay_menu,
|
||||
F.data == "subscription_autopay"
|
||||
@@ -2729,3 +3032,8 @@ def register_handlers(dp: Dispatcher):
|
||||
handle_subscription_settings,
|
||||
F.data == "subscription_settings"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_no_traffic_packages,
|
||||
F.data == "no_traffic_packages"
|
||||
)
|
||||
|
||||
+148
-58
@@ -1,4 +1,5 @@
|
||||
from typing import List, Optional
|
||||
from aiogram import types
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
|
||||
@@ -20,20 +21,29 @@ def get_main_menu_keyboard(
|
||||
is_admin: bool = False,
|
||||
has_had_paid_subscription: bool = False,
|
||||
has_active_subscription: bool = False,
|
||||
subscription_is_active: bool = False
|
||||
subscription_is_active: bool = False,
|
||||
balance_kopeks: int = 0
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
if settings.DEBUG:
|
||||
print(f"DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}")
|
||||
print(f"DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}, balance={balance_kopeks}")
|
||||
|
||||
if hasattr(texts, 'BALANCE_BUTTON') and balance_kopeks > 0:
|
||||
balance_button_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(balance_kopeks))
|
||||
else:
|
||||
balance_button_text = f"💰 Баланс: {texts.format_price(balance_kopeks)}"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text=texts.MENU_BALANCE, callback_data="menu_balance"),
|
||||
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
|
||||
]
|
||||
]
|
||||
|
||||
if has_active_subscription and subscription_is_active:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
show_trial = not has_had_paid_subscription and not has_active_subscription
|
||||
|
||||
@@ -107,15 +117,28 @@ def get_subscription_keyboard(
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
|
||||
if connect_mode == "miniapp_subscription":
|
||||
button_text = "🚀 Открыть в мини-приложении"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🚀 Открыть в мини-приложении",
|
||||
web_app=types.WebAppInfo(url=subscription.subscription_url)
|
||||
)
|
||||
])
|
||||
elif connect_mode == "miniapp_custom":
|
||||
button_text = "🚀 Открыть приложение"
|
||||
if settings.MINIAPP_CUSTOM_URL:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🚀 Открыть приложение",
|
||||
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
|
||||
])
|
||||
else:
|
||||
button_text = "🔗 Подключиться"
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=button_text, callback_data="subscription_connect")
|
||||
])
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
|
||||
])
|
||||
|
||||
if not is_trial and subscription and subscription.days_left <= 3:
|
||||
keyboard.append([
|
||||
@@ -142,27 +165,31 @@ def get_subscription_keyboard(
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_subscription_settings_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
def get_subscription_settings_keyboard(language: str = "ru", show_countries_management: bool = True) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = [
|
||||
[
|
||||
keyboard = []
|
||||
|
||||
if show_countries_management:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🌍 Добавить страны", callback_data="subscription_add_countries")
|
||||
],
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="📱 Добавить устройства", callback_data="subscription_add_devices")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data="subscription_reset_devices")
|
||||
]
|
||||
]
|
||||
])
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
keyboard.insert(1, [
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text="📈 Добавить трафик", callback_data="subscription_add_traffic")
|
||||
])
|
||||
keyboard.insert(2, [
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic")
|
||||
])
|
||||
|
||||
@@ -215,28 +242,57 @@ def get_subscription_period_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
|
||||
|
||||
def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
return get_back_keyboard(language)
|
||||
|
||||
logger.info(f"🔍 RAW CONFIG: '{settings.TRAFFIC_PACKAGES_CONFIG}'")
|
||||
|
||||
all_packages = settings.get_traffic_packages()
|
||||
logger.info(f"🔍 ALL PACKAGES: {all_packages}")
|
||||
|
||||
enabled_packages = [pkg for pkg in all_packages if pkg['enabled']]
|
||||
disabled_packages = [pkg for pkg in all_packages if not pkg['enabled']]
|
||||
|
||||
logger.info(f"🔍 ENABLED: {len(enabled_packages)} packages")
|
||||
logger.info(f"🔍 DISABLED: {len(disabled_packages)} packages")
|
||||
|
||||
for pkg in disabled_packages:
|
||||
logger.info(f"🔍 DISABLED PACKAGE: {pkg['gb']}GB = {pkg['price']} kopeks, enabled={pkg['enabled']}")
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
packages = [
|
||||
(5, texts.TRAFFIC_5GB),
|
||||
(10, texts.TRAFFIC_10GB),
|
||||
(25, texts.TRAFFIC_25GB),
|
||||
(50, texts.TRAFFIC_50GB),
|
||||
(100, texts.TRAFFIC_100GB),
|
||||
(250, texts.TRAFFIC_250GB),
|
||||
(0, texts.TRAFFIC_UNLIMITED)
|
||||
]
|
||||
traffic_packages = settings.get_traffic_packages()
|
||||
|
||||
for gb, text in packages:
|
||||
for package in traffic_packages:
|
||||
gb = package["gb"]
|
||||
price = package["price"]
|
||||
enabled = package["enabled"]
|
||||
|
||||
if not enabled:
|
||||
continue
|
||||
|
||||
if gb == 0:
|
||||
text = f"♾️ Безлимит - {settings.format_price(package['price'])}"
|
||||
else:
|
||||
text = f"📊 {gb} ГБ - {settings.format_price(package['price'])}"
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"traffic_{gb}")
|
||||
])
|
||||
|
||||
if not keyboard:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⚠️ Пакеты трафика не настроены",
|
||||
callback_data="no_traffic_packages"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="subscription_config_back")
|
||||
@@ -244,7 +300,6 @@ def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_countries_keyboard(countries: List[dict], selected: List[str], language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
@@ -287,17 +342,31 @@ def get_devices_keyboard(current: int, language: str = "ru") -> InlineKeyboardMa
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
for devices in range(1, 6):
|
||||
price = (devices - 1) * settings.PRICE_PER_DEVICE
|
||||
price_text = f" (+{texts.format_price(price)})" if price > 0 else ""
|
||||
start_devices = settings.DEFAULT_DEVICE_LIMIT
|
||||
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 50
|
||||
end_devices = min(max_devices + 1, start_devices + 10)
|
||||
|
||||
buttons = []
|
||||
|
||||
for devices in range(start_devices, end_devices):
|
||||
price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
price_text = f" (+{texts.format_price(price)})" if price > 0 else " (вкл.)"
|
||||
emoji = "✅" if devices == current else "⚪"
|
||||
|
||||
keyboard.append([
|
||||
button_text = f"{emoji} {devices}{price_text}"
|
||||
|
||||
buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{emoji} {devices} устройство{_get_device_suffix(devices)}{price_text}",
|
||||
text=button_text,
|
||||
callback_data=f"devices_{devices}"
|
||||
)
|
||||
])
|
||||
)
|
||||
|
||||
for i in range(0, len(buttons), 2):
|
||||
if i + 1 < len(buttons):
|
||||
keyboard.append([buttons[i], buttons[i + 1]])
|
||||
else:
|
||||
keyboard.append([buttons[i]])
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text="✅ Продолжить", callback_data="devices_continue")],
|
||||
@@ -306,6 +375,13 @@ def get_devices_keyboard(current: int, language: str = "ru") -> InlineKeyboardMa
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def _get_device_declension(count: int) -> str:
|
||||
if count % 10 == 1 and count % 100 != 11:
|
||||
return "устройство"
|
||||
elif count % 10 in [2, 3, 4] and count % 100 not in [12, 13, 14]:
|
||||
return "устройства"
|
||||
else:
|
||||
return "устройств"
|
||||
|
||||
def get_subscription_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
@@ -500,16 +576,6 @@ def get_pagination_keyboard(
|
||||
|
||||
return keyboard
|
||||
|
||||
|
||||
def _get_device_suffix(count: int) -> str:
|
||||
if count == 1:
|
||||
return ""
|
||||
elif 2 <= count <= 4:
|
||||
return "а"
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
def get_confirmation_keyboard(
|
||||
confirm_data: str,
|
||||
cancel_data: str = "cancel",
|
||||
@@ -599,44 +665,68 @@ def get_add_traffic_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
packages = [
|
||||
(5, f"📊 +5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"),
|
||||
(10, f"📊 +10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"),
|
||||
(25, f"📊 +25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"),
|
||||
(50, f"📊 +50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"),
|
||||
(100, f"📊 +100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"),
|
||||
(0, f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}")
|
||||
]
|
||||
traffic_packages = settings.get_traffic_packages()
|
||||
|
||||
for gb, text in packages:
|
||||
for package in traffic_packages:
|
||||
gb = package["gb"]
|
||||
price = package["price"]
|
||||
enabled = package["enabled"]
|
||||
|
||||
if not enabled:
|
||||
continue
|
||||
|
||||
if gb == 0:
|
||||
text = f"📊 Безлимит - {settings.format_price(package['price'])}"
|
||||
else:
|
||||
text = f"📊 +{gb} ГБ - {settings.format_price(package['price'])}"
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}")
|
||||
])
|
||||
|
||||
if not keyboard:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⚠️ Пакеты трафика не настроены",
|
||||
callback_data="no_traffic_packages"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_add_devices_keyboard(current_devices: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
max_devices = 10
|
||||
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 100
|
||||
|
||||
for add_count in range(1, min(6, max_devices - current_devices + 1)):
|
||||
max_add = min(5, max_devices - current_devices)
|
||||
|
||||
for add_count in range(1, max_add + 1):
|
||||
price = add_count * settings.PRICE_PER_DEVICE
|
||||
total_devices = current_devices + add_count
|
||||
|
||||
add_device_word = _get_device_declension(add_count)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📱 +{add_count} устройство{_get_device_suffix(add_count)} (итого: {total_devices}) - {settings.format_price(price)}",
|
||||
text=f"📱 +{add_count} {add_device_word} (итого: {total_devices}) - {settings.format_price(price)}",
|
||||
callback_data=f"add_devices_{add_count}"
|
||||
)
|
||||
])
|
||||
|
||||
if max_add == 0:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⚠️ Достигнут максимум устройств",
|
||||
callback_data="max_devices_reached"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
@@ -105,19 +105,17 @@ class RussianTexts(Texts):
|
||||
REFERRAL_CODE_APPLIED = "🎁 Реферальный код применен! Вы получите бонус после первой покупки."
|
||||
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
|
||||
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
|
||||
|
||||
|
||||
MAIN_MENU = """👤 <b>{user_name}</b>
|
||||
━━━━━━━━━━━━━━━━━
|
||||
💰 <b>Баланс:</b> {balance}
|
||||
|
||||
📱 <b>Подписка:</b> {subscription_status}
|
||||
━━━━━━━━━━━━━━━━━
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
MENU_BALANCE = "💰 Баланс"
|
||||
MENU_SUBSCRIPTION = "📱 Подписка"
|
||||
MENU_TRIAL = "🎁 Тестовая подписка"
|
||||
MENU_TRIAL = "🧪 Тестовая подписка"
|
||||
MENU_BUY_SUBSCRIPTION = "💎 Купить подписку"
|
||||
MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку"
|
||||
MENU_PROMOCODE = "🎫 Промокод"
|
||||
@@ -126,9 +124,11 @@ class RussianTexts(Texts):
|
||||
MENU_RULES = "📋 Правила сервиса"
|
||||
MENU_LANGUAGE = "🌐 Язык"
|
||||
MENU_ADMIN = "⚙️ Админ-панель"
|
||||
BALANCE_BUTTON = "💰 Баланс: {balance}"
|
||||
BALANCE_BUTTON_ZERO = "💰 Баланс: 0.00 ₽"
|
||||
|
||||
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
|
||||
SUBSCRIPTION_TRIAL = "🎁 Тестовая подписка"
|
||||
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
|
||||
SUBSCRIPTION_ACTIVE = "✅ Активна"
|
||||
SUBSCRIPTION_EXPIRED = "⏰ Истекла"
|
||||
|
||||
|
||||
+28
-2
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject, User as TgUser
|
||||
@@ -56,7 +57,7 @@ class AuthMiddleware(BaseMiddleware):
|
||||
)
|
||||
|
||||
if is_registration_process:
|
||||
logger.info(f"📝 Пропускаем пользователя {user.id} в процессе регистрации")
|
||||
logger.info(f"🔍 Пропускаем пользователя {user.id} в процессе регистрации")
|
||||
data['db'] = db
|
||||
data['db_user'] = None
|
||||
data['is_admin'] = False
|
||||
@@ -124,8 +125,33 @@ class AuthMiddleware(BaseMiddleware):
|
||||
logger.info(f"❌ Удаленный пользователь {user.id} попытался использовать бота без /start")
|
||||
return
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
profile_updated = False
|
||||
|
||||
if db_user.username != user.username:
|
||||
old_username = db_user.username
|
||||
db_user.username = user.username
|
||||
logger.info(f"📝 [Middleware] Username обновлен для {user.id}: '{old_username}' → '{db_user.username}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.first_name != user.first_name:
|
||||
old_first_name = db_user.first_name
|
||||
db_user.first_name = user.first_name
|
||||
logger.info(f"📝 [Middleware] Имя обновлено для {user.id}: '{old_first_name}' → '{db_user.first_name}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.last_name != user.last_name:
|
||||
old_last_name = db_user.last_name
|
||||
db_user.last_name = user.last_name
|
||||
logger.info(f"📝 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}' → '{db_user.last_name}'")
|
||||
profile_updated = True
|
||||
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
|
||||
if profile_updated:
|
||||
db_user.updated_at = datetime.utcnow()
|
||||
logger.info(f"💾 [Middleware] Профиль пользователя {user.id} обновлен в middleware")
|
||||
|
||||
await db.commit()
|
||||
|
||||
data['db'] = db
|
||||
|
||||
@@ -28,8 +28,15 @@ class MaintenanceService:
|
||||
self._status = MaintenanceStatus(is_active=False)
|
||||
self._check_task: Optional[asyncio.Task] = None
|
||||
self._is_checking = False
|
||||
self._max_consecutive_failures = 3
|
||||
self._max_consecutive_failures = 3
|
||||
self._bot = None
|
||||
self._last_notification_sent = None
|
||||
|
||||
def set_bot(self, bot):
|
||||
"""Устанавливает ссылку на бота для отправки уведомлений"""
|
||||
self._bot = bot
|
||||
logger.info("Бот установлен для maintenance_service")
|
||||
|
||||
@property
|
||||
def status(self) -> MaintenanceStatus:
|
||||
return self._status
|
||||
@@ -51,6 +58,51 @@ class MaintenanceService:
|
||||
else:
|
||||
return settings.get_maintenance_message()
|
||||
|
||||
async def _notify_admins(self, message: str, alert_type: str = "info"):
|
||||
"""Отправка уведомлений администраторам"""
|
||||
if not self._bot:
|
||||
logger.warning("Бот не установлен, уведомления не могут быть отправлены")
|
||||
return
|
||||
|
||||
cache_key = f"maintenance_notification_{alert_type}"
|
||||
if await cache.get(cache_key):
|
||||
return
|
||||
|
||||
admin_ids = settings.get_admin_ids()
|
||||
if not admin_ids:
|
||||
logger.warning("Список администраторов пуст")
|
||||
return
|
||||
|
||||
emoji_map = {
|
||||
"error": "🚨",
|
||||
"warning": "⚠️",
|
||||
"success": "✅",
|
||||
"info": "ℹ️"
|
||||
}
|
||||
emoji = emoji_map.get(alert_type, "ℹ️")
|
||||
|
||||
formatted_message = f"{emoji} <b>Maintenance Service</b>\n\n{message}"
|
||||
|
||||
success_count = 0
|
||||
for admin_id in admin_ids:
|
||||
try:
|
||||
await self._bot.send_message(
|
||||
chat_id=admin_id,
|
||||
text=formatted_message,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
success_count += 1
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления админу {admin_id}: {e}")
|
||||
|
||||
if success_count > 0:
|
||||
logger.info(f"Уведомление отправлено {success_count} администраторам")
|
||||
await cache.set(cache_key, True, expire=300)
|
||||
else:
|
||||
logger.error("Не удалось отправить уведомления ни одному администратору")
|
||||
|
||||
async def enable_maintenance(self, reason: Optional[str] = None, auto: bool = False) -> bool:
|
||||
try:
|
||||
if self._status.is_active:
|
||||
@@ -64,6 +116,18 @@ class MaintenanceService:
|
||||
|
||||
await self._save_status_to_cache()
|
||||
|
||||
notification_msg = f"""
|
||||
Режим технических работ ВКЛЮЧЕН
|
||||
|
||||
📋 <b>Причина:</b> {self._status.reason}
|
||||
🤖 <b>Автоматически:</b> {'Да' if auto else 'Нет'}
|
||||
🕐 <b>Время:</b> {self._status.enabled_at.strftime('%d.%m.%Y %H:%M:%S')}
|
||||
|
||||
Обычные пользователи временно не смогут использовать бота.
|
||||
"""
|
||||
|
||||
await self._notify_admins(notification_msg, "warning" if auto else "info")
|
||||
|
||||
logger.warning(f"🔧 Режим техработ ВКЛЮЧЕН. Причина: {self._status.reason}")
|
||||
return True
|
||||
|
||||
@@ -77,6 +141,11 @@ class MaintenanceService:
|
||||
logger.info("Режим техработ уже выключен")
|
||||
return True
|
||||
|
||||
was_auto = self._status.auto_enabled
|
||||
duration = None
|
||||
if self._status.enabled_at:
|
||||
duration = datetime.utcnow() - self._status.enabled_at
|
||||
|
||||
self._status.is_active = False
|
||||
self._status.enabled_at = None
|
||||
self._status.reason = None
|
||||
@@ -85,6 +154,27 @@ class MaintenanceService:
|
||||
|
||||
await self._save_status_to_cache()
|
||||
|
||||
duration_str = ""
|
||||
if duration:
|
||||
hours = int(duration.total_seconds() // 3600)
|
||||
minutes = int((duration.total_seconds() % 3600) // 60)
|
||||
if hours > 0:
|
||||
duration_str = f"\n⏱️ <b>Длительность:</b> {hours}ч {minutes}мин"
|
||||
else:
|
||||
duration_str = f"\n⏱️ <b>Длительность:</b> {minutes}мин"
|
||||
|
||||
notification_msg = f"""
|
||||
Режим технических работ ВЫКЛЮЧЕН
|
||||
|
||||
🤖 <b>Автоматически:</b> {'Да' if was_auto else 'Нет'}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')}
|
||||
{duration_str}
|
||||
|
||||
Сервис снова доступен для пользователей.
|
||||
"""
|
||||
|
||||
await self._notify_admins(notification_msg, "success")
|
||||
|
||||
logger.info("✅ Режим техработ ВЫКЛЮЧЕН")
|
||||
return True
|
||||
|
||||
@@ -102,6 +192,17 @@ class MaintenanceService:
|
||||
|
||||
self._check_task = asyncio.create_task(self._monitoring_loop())
|
||||
logger.info(f"🔄 Запущен мониторинг API RemnaWave (интервал: {settings.get_maintenance_check_interval()}с)")
|
||||
|
||||
await self._notify_admins(f"""
|
||||
Мониторинг технических работ запущен
|
||||
|
||||
🔄 <b>Интервал проверки:</b> {settings.get_maintenance_check_interval()} секунд
|
||||
🤖 <b>Автовключение:</b> {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
|
||||
🎯 <b>Порог ошибок:</b> {self._max_consecutive_failures}
|
||||
|
||||
Система будет следить за доступностью API.
|
||||
""", "info")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -117,7 +218,8 @@ class MaintenanceService:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info("⏹️ Мониторинг API остановлен")
|
||||
await self._notify_admins("Мониторинг технических работ остановлен", "info")
|
||||
logger.info("ℹ️ Мониторинг API остановлен")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -138,6 +240,18 @@ class MaintenanceService:
|
||||
is_connected = await test_api_connection(api)
|
||||
|
||||
if is_connected:
|
||||
# API восстановилось
|
||||
if not self._status.api_status:
|
||||
await self._notify_admins(f"""
|
||||
API RemnaWave восстановлено!
|
||||
|
||||
✅ <b>Статус:</b> Доступно
|
||||
🕐 <b>Время восстановления:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🔄 <b>Неудачных попыток было:</b> {self._status.consecutive_failures}
|
||||
|
||||
API снова отвечает на запросы.
|
||||
""", "success")
|
||||
|
||||
self._status.api_status = True
|
||||
self._status.consecutive_failures = 0
|
||||
|
||||
@@ -147,9 +261,21 @@ class MaintenanceService:
|
||||
|
||||
return True
|
||||
else:
|
||||
was_available = self._status.api_status
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
|
||||
if was_available:
|
||||
await self._notify_admins(f"""
|
||||
API RemnaWave недоступно!
|
||||
|
||||
❌ <b>Статус:</b> Недоступно
|
||||
🕐 <b>Время обнаружения:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🔄 <b>Попытка:</b> {self._status.consecutive_failures}
|
||||
|
||||
Началась серия неудачных проверок API.
|
||||
""", "error")
|
||||
|
||||
if (self._status.consecutive_failures >= self._max_consecutive_failures and
|
||||
not self._status.is_active and
|
||||
settings.is_maintenance_auto_enable()):
|
||||
@@ -163,6 +289,17 @@ class MaintenanceService:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки API: {e}")
|
||||
|
||||
if self._status.api_status:
|
||||
await self._notify_admins(f"""
|
||||
Ошибка при проверке API RemnaWave
|
||||
|
||||
❌ <b>Ошибка:</b> {str(e)}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%H:%M:%S')}
|
||||
|
||||
Не удалось выполнить проверку доступности API.
|
||||
""", "error")
|
||||
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
return False
|
||||
@@ -216,7 +353,7 @@ class MaintenanceService:
|
||||
if status_data.get("last_check"):
|
||||
self._status.last_check = datetime.fromisoformat(status_data["last_check"])
|
||||
|
||||
logger.info(f"📥 Состояние техработ загружено из кеша: активен={self._status.is_active}")
|
||||
logger.info(f"🔥 Состояние техработ загружено из кеша: активен={self._status.is_active}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки состояния из кеша: {e}")
|
||||
@@ -232,7 +369,8 @@ class MaintenanceService:
|
||||
"consecutive_failures": self._status.consecutive_failures,
|
||||
"monitoring_active": self._check_task is not None and not self._check_task.done(),
|
||||
"auto_enable_configured": settings.is_maintenance_auto_enable(),
|
||||
"check_interval": settings.get_maintenance_check_interval()
|
||||
"check_interval": settings.get_maintenance_check_interval(),
|
||||
"bot_connected": self._bot is not None
|
||||
}
|
||||
|
||||
async def force_api_check(self) -> Dict[str, Any]:
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.yookassa import create_yookassa_payment, link_yookassa_payment_to_transaction
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
@@ -22,6 +23,7 @@ class PaymentService:
|
||||
def __init__(self, bot: Optional[Bot] = None):
|
||||
self.bot = bot
|
||||
self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None
|
||||
self.stars_service = TelegramStarsService(bot) if bot else None
|
||||
|
||||
async def create_stars_invoice(
|
||||
self,
|
||||
@@ -30,28 +32,90 @@ class PaymentService:
|
||||
payload: Optional[str] = None
|
||||
) -> str:
|
||||
|
||||
if not self.bot:
|
||||
if not self.bot or not self.stars_service:
|
||||
raise ValueError("Bot instance required for Stars payments")
|
||||
|
||||
try:
|
||||
stars_amount = max(1, amount_kopeks // 100)
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title="Пополнение баланса VPN",
|
||||
description=description,
|
||||
description=f"{description} (≈{stars_amount} ⭐)",
|
||||
payload=payload or f"balance_topup_{amount_kopeks}",
|
||||
provider_token="",
|
||||
currency="XTR",
|
||||
prices=[LabeledPrice(label="Пополнение", amount=stars_amount)]
|
||||
)
|
||||
|
||||
logger.info(f"Создан Stars invoice на {stars_amount} звезд")
|
||||
logger.info(f"Создан Stars invoice на {stars_amount} звезд (~{amount_rubles:.2f}₽)")
|
||||
return invoice_link
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
raise
|
||||
|
||||
async def process_stars_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
stars_amount: int,
|
||||
payload: str,
|
||||
telegram_payment_charge_id: str
|
||||
) -> bool:
|
||||
try:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
|
||||
amount_kopeks = int(rubles_amount * 100)
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
|
||||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||||
external_id=telegram_payment_charge_id,
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user:
|
||||
await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks,
|
||||
f"Пополнение Stars: {rubles_amount:.2f}₽ ({stars_amount} ⭐)"
|
||||
)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"⭐ Звезд: {stars_amount}\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🏦 Способ: Telegram Stars\n"
|
||||
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {rubles_amount:.2f}₽")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
|
||||
|
||||
logger.info(
|
||||
f"✅ Обработан Stars платеж: пользователь {user_id}, "
|
||||
f"{stars_amount} звезд → {rubles_amount:.2f}₽"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Пользователь с ID {user_id} не найден при обработке Stars платежа")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки Stars платежа: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def create_yookassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -196,7 +260,7 @@ class PaymentService:
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(updated_payment.amount_kopeks)}\n"
|
||||
f"🏦 Способ: Банковская карта\n"
|
||||
f"🦐 Способ: Банковская карта\n"
|
||||
f"🆔 Транзакция: {yookassa_payment_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
|
||||
@@ -25,7 +25,8 @@ class RemnaWaveService:
|
||||
def __init__(self):
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=settings.REMNAWAVE_API_URL,
|
||||
api_key=settings.REMNAWAVE_API_KEY
|
||||
api_key=settings.REMNAWAVE_API_KEY,
|
||||
secret_key=settings.REMNAWAVE_SECRET_KEY
|
||||
)
|
||||
|
||||
def _parse_remnawave_date(self, date_str: str) -> datetime:
|
||||
|
||||
@@ -229,36 +229,40 @@ class SubscriptionService:
|
||||
devices: int,
|
||||
db: AsyncSession
|
||||
) -> Tuple[int, List[int]]:
|
||||
|
||||
from app.config import PERIOD_PRICES, TRAFFIC_PRICES
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
from app.database.crud.server_squad import get_server_squad_by_id
|
||||
|
||||
|
||||
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)
|
||||
traffic_price = TRAFFIC_PRICES.get(traffic_gb, 0)
|
||||
|
||||
|
||||
traffic_price = settings.get_traffic_price(traffic_gb)
|
||||
|
||||
server_prices = []
|
||||
total_servers_price = 0
|
||||
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available and not server.is_full:
|
||||
server_prices.append(server.price_kopeks)
|
||||
total_servers_price += server.price_kopeks
|
||||
logger.debug(f"🏷️ Сервер {server.display_name}: {server.price_kopeks/100}₽")
|
||||
logger.debug(f"Сервер {server.display_name}: {server.price_kopeks/100}₽")
|
||||
else:
|
||||
server_prices.append(0)
|
||||
logger.warning(f"⚠️ Сервер ID {server_id} недоступен")
|
||||
|
||||
devices_price = max(0, devices - 1) * settings.PRICE_PER_DEVICE
|
||||
logger.warning(f"Сервер ID {server_id} недоступен")
|
||||
|
||||
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_price = base_price + traffic_price + total_servers_price + devices_price
|
||||
|
||||
logger.info(f"💰 Расчет стоимости новой подписки:")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
logger.info(f" 📊 Трафик {traffic_gb} ГБ: {traffic_price/100}₽")
|
||||
logger.info(f" 🌍 Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
logger.info(f" 📱 Устройства ({devices}): {devices_price/100}₽")
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
logger.info(f"Расчет стоимости новой подписки:")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽")
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
logger.info(f" Устройства ({devices}): {devices_price/100}₽")
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
@@ -269,7 +273,7 @@ class SubscriptionService:
|
||||
db: AsyncSession
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES, TRAFFIC_PRICES
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
@@ -277,9 +281,9 @@ class SubscriptionService:
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
|
||||
devices_price = max(0, subscription.device_limit - 1) * settings.PRICE_PER_DEVICE
|
||||
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
traffic_price = TRAFFIC_PRICES.get(subscription.traffic_limit_gb, 0)
|
||||
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
|
||||
total_price = base_price + servers_price + devices_price + traffic_price
|
||||
|
||||
@@ -364,7 +368,7 @@ class SubscriptionService:
|
||||
prices_list.append(price)
|
||||
logger.debug(f"🏷️ Страна {server.display_name}: {price/100}₽")
|
||||
else:
|
||||
default_price = 1000
|
||||
default_price = 0
|
||||
total_price += default_price
|
||||
prices_list.append(default_price)
|
||||
logger.warning(f"⚠️ Сервер {country_uuid} недоступен, используем базовую цену: {default_price/100}₽")
|
||||
@@ -374,7 +378,7 @@ class SubscriptionService:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения цен стран: {e}")
|
||||
default_prices = [1000] * len(country_uuids)
|
||||
default_prices = [0] * len(country_uuids)
|
||||
return sum(default_prices), default_prices
|
||||
|
||||
async def _get_countries_price(self, country_uuids: List[str], db: AsyncSession) -> int:
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import delete, select, update
|
||||
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id, get_user_by_telegram_id, get_users_list,
|
||||
@@ -13,7 +13,7 @@ from app.database.crud.transaction import get_user_transactions_count
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.models import (
|
||||
User, UserStatus, Subscription, Transaction, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
@@ -242,9 +242,81 @@ class UserService:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован")
|
||||
logger.info(f" ✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка деактивации RemnaWave пользователя: {e}")
|
||||
logger.warning(f" ⚠️ Ошибка деактивации RemnaWave: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import YooKassaPayment
|
||||
from sqlalchemy import select
|
||||
|
||||
yookassa_result = await db.execute(
|
||||
select(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
|
||||
)
|
||||
yookassa_payments = yookassa_result.scalars().all()
|
||||
|
||||
if yookassa_payments:
|
||||
logger.info(f" 🔄 Удаляем {len(yookassa_payments)} YooKassa платежей")
|
||||
await db.execute(
|
||||
delete(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f" ✅ YooKassa платежи удалены")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка удаления YooKassa платежей: {e}")
|
||||
|
||||
try:
|
||||
transactions_result = await db.execute(
|
||||
select(Transaction).where(Transaction.user_id == user_id)
|
||||
)
|
||||
transactions = transactions_result.scalars().all()
|
||||
|
||||
if transactions:
|
||||
logger.info(f" 🔄 Удаляем {len(transactions)} транзакций")
|
||||
await db.execute(
|
||||
delete(Transaction).where(Transaction.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f" ✅ Транзакции удалены")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка удаления транзакций: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f" 🗑️ Удалены использования промокодов пользователя {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка удаления использований промокодов: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f" 🗑️ Удалены реферальные доходы пользователя {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка удаления реферальных доходов: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.referral_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f" 🗑️ Удалены реферальные записи о пользователе {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка удаления реферальных записей: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import BroadcastHistory
|
||||
await db.execute(
|
||||
delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f" 🗑️ Удалена история рассылок админа {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка удаления истории рассылок: {e}")
|
||||
|
||||
if user.subscription:
|
||||
try:
|
||||
@@ -253,74 +325,47 @@ class UserService:
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
)
|
||||
)
|
||||
logger.info(f"🗑️ Удалены записи SubscriptionServer для подписки {user.subscription.id}")
|
||||
await db.flush()
|
||||
logger.info(f" 🗑️ Удалены записи SubscriptionServer для подписки {user.subscription.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления SubscriptionServer: {e}")
|
||||
logger.error(f" ❌ Ошибка удаления SubscriptionServer: {e}")
|
||||
|
||||
if user.subscription:
|
||||
try:
|
||||
await db.delete(user.subscription)
|
||||
logger.info(f"🗑️ Удалена подписка пользователя {user_id}")
|
||||
from app.database.models import Subscription
|
||||
await db.execute(
|
||||
delete(Subscription).where(Subscription.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f" 🗑️ Удалена подписка пользователя {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления подписки: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)
|
||||
)
|
||||
logger.info(f"🗑️ Удалены использования промокодов пользователя {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления использований промокодов: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.user_id == user.id)
|
||||
)
|
||||
logger.info(f"🗑️ Удалены реферальные доходы пользователя {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления реферальных доходов: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)
|
||||
)
|
||||
logger.info(f"🗑️ Удалены реферальные записи о пользователе {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления реферальных записей: {e}")
|
||||
logger.error(f" ❌ Ошибка удаления подписки: {e}")
|
||||
|
||||
try:
|
||||
from sqlalchemy import update
|
||||
await db.execute(
|
||||
referrals_result = await db.execute(
|
||||
update(User)
|
||||
.where(User.referred_by_id == user.id)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.values(referred_by_id=None)
|
||||
)
|
||||
logger.info(f"🗑️ Очищены реферальные ссылки у рефералов пользователя {user_id}")
|
||||
if referrals_result.rowcount > 0:
|
||||
logger.info(f" 🔗 Очищены реферальные ссылки у {referrals_result.rowcount} рефералов")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка очистки реферальных ссылок: {e}")
|
||||
logger.error(f" ❌ Ошибка очистки реферальных ссылок: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(Transaction).where(Transaction.user_id == user.id)
|
||||
delete(User).where(User.id == user_id)
|
||||
)
|
||||
logger.info(f"🗑️ Удалены транзакции пользователя {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления транзакций: {e}")
|
||||
|
||||
try:
|
||||
user.status = UserStatus.DELETED.value
|
||||
user.balance_kopeks = 0
|
||||
user.remnawave_uuid = None
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ Пользователь {user_id} помечен как удаленный и обнулен")
|
||||
logger.info(f" ✅ Пользователь {user_id} окончательно удален из базы")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления статуса пользователя: {e}")
|
||||
logger.error(f" ❌ Ошибка финального удаления пользователя: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
logger.info(f"✅ Пользователь {user_id} (Telegram ID: {user.telegram_id}) полностью удален админом {admin_id}")
|
||||
logger.info(f"✅ Пользователь {user.telegram_id} (ID: {user_id}) полностью удален администратором {admin_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: remnawave_bot_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: remnawave_bot_redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
bot:
|
||||
image: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
container_name: remnawave_bot
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-remnawave_user}:${POSTGRES_PASSWORD:-secure_password_123}@postgres:5432/${POSTGRES_DB:-remnawave_bot}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
volumes:
|
||||
- ./logs:/app/logs:rw
|
||||
- ./data:/app/data:rw
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
|
||||
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
|
||||
networks:
|
||||
- bot_network
|
||||
user: "1000:1000"
|
||||
command: >
|
||||
bash -c "
|
||||
mkdir -p /app/logs /app/data &&
|
||||
python main.py
|
||||
"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
@@ -77,6 +77,8 @@ async def main():
|
||||
bot, dp = await setup_bot()
|
||||
|
||||
monitoring_service.bot = bot
|
||||
maintenance_service.set_bot(bot)
|
||||
logger.info("🔗 Бот подключен к сервисам мониторинга и техработ")
|
||||
|
||||
payment_service = PaymentService(bot)
|
||||
|
||||
@@ -95,11 +97,16 @@ async def main():
|
||||
else:
|
||||
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
|
||||
|
||||
logger.info("🔍 Запуск службы мониторинга...")
|
||||
logger.info("📊 Запуск службы мониторинга...")
|
||||
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
|
||||
|
||||
logger.info("🔧 Запуск службы техработ...")
|
||||
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
|
||||
logger.info("🔧 Проверка службы техработ...")
|
||||
if not maintenance_service._check_task or maintenance_service._check_task.done():
|
||||
logger.info("🔧 Запуск службы техработ...")
|
||||
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
|
||||
else:
|
||||
logger.info("🔧 Служба техработ уже запущена")
|
||||
maintenance_task = None
|
||||
|
||||
logger.info("🔄 Запуск polling...")
|
||||
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
|
||||
@@ -131,7 +138,7 @@ async def main():
|
||||
logger.error(f"Служба мониторинга завершилась с ошибкой: {exception}")
|
||||
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
|
||||
|
||||
if maintenance_task.done():
|
||||
if maintenance_task and maintenance_task.done():
|
||||
exception = maintenance_task.exception()
|
||||
if exception:
|
||||
logger.error(f"Служба техработ завершилась с ошибкой: {exception}")
|
||||
@@ -154,7 +161,7 @@ async def main():
|
||||
logger.info("🛑 Начинается корректное завершение работы...")
|
||||
|
||||
if yookassa_server_task and not yookassa_server_task.done():
|
||||
logger.info("⏹️ Остановка YooKassa webhook сервера...")
|
||||
logger.info("ℹ️ Остановка YooKassa webhook сервера...")
|
||||
yookassa_server_task.cancel()
|
||||
try:
|
||||
await yookassa_server_task
|
||||
@@ -162,7 +169,7 @@ async def main():
|
||||
pass
|
||||
|
||||
if monitoring_task and not monitoring_task.done():
|
||||
logger.info("⏹️ Остановка службы мониторинга...")
|
||||
logger.info("ℹ️ Остановка службы мониторинга...")
|
||||
monitoring_service.stop_monitoring()
|
||||
monitoring_task.cancel()
|
||||
try:
|
||||
@@ -171,7 +178,7 @@ async def main():
|
||||
pass
|
||||
|
||||
if maintenance_task and not maintenance_task.done():
|
||||
logger.info("⏹️ Остановка службы техработ...")
|
||||
logger.info("ℹ️ Остановка службы техработ...")
|
||||
await maintenance_service.stop_monitoring()
|
||||
maintenance_task.cancel()
|
||||
try:
|
||||
@@ -180,7 +187,7 @@ async def main():
|
||||
pass
|
||||
|
||||
if polling_task and not polling_task.done():
|
||||
logger.info("⏹️ Остановка polling...")
|
||||
logger.info("ℹ️ Остановка polling...")
|
||||
polling_task.cancel()
|
||||
try:
|
||||
await polling_task
|
||||
@@ -188,7 +195,7 @@ async def main():
|
||||
pass
|
||||
|
||||
if webhook_server:
|
||||
logger.info("⏹️ Остановка Tribute webhook сервера...")
|
||||
logger.info("ℹ️ Остановка Tribute webhook сервера...")
|
||||
await webhook_server.stop()
|
||||
|
||||
if 'bot' in locals():
|
||||
|
||||
Reference in New Issue
Block a user